mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
36 Commits
v1.0.44
...
feat/short
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cd34b1f70 | ||
|
|
2bbab4d851 | ||
|
|
98173ae5a9 | ||
|
|
c8e205eed2 | ||
|
|
04932c2421 | ||
|
|
531d7265b5 | ||
|
|
6d7f8ba442 | ||
|
|
b216363e63 | ||
|
|
b0b163d0ef | ||
|
|
0aa9e96d18 | ||
|
|
e57d97f341 | ||
|
|
57ba4fae61 | ||
|
|
925ae5ecd6 | ||
|
|
4710a294f5 | ||
|
|
bc8e9bd6ef | ||
|
|
f65712cacf | ||
|
|
915cc623cc | ||
|
|
3bfb80951d | ||
|
|
639259fbfd | ||
|
|
0bdd7de807 | ||
|
|
99e314fe0b | ||
|
|
50b3f0a2af | ||
|
|
b1ecf2d0f9 | ||
|
|
9a53a1f2b8 | ||
|
|
eb6f5aa60a | ||
|
|
c4eb18cecc | ||
|
|
a510e07dfc | ||
|
|
f83c79825d | ||
|
|
9adc79d0c1 | ||
|
|
6b4bc0cc64 | ||
|
|
b5cd535285 | ||
|
|
098659cc18 | ||
|
|
8d8acb8252 | ||
|
|
ccf654d3f0 | ||
|
|
ad4368ed2a | ||
|
|
a07239b923 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -82,6 +82,8 @@ jobs:
|
||||
run: python3 scripts/fetch_meta.py
|
||||
- name: Run golangci-lint
|
||||
run: go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.1.6 run --new-from-rev=origin/main
|
||||
- name: Run errs/ lint guards (lintcheck)
|
||||
run: go run -C lint . ..
|
||||
|
||||
coverage:
|
||||
needs: fast-gate
|
||||
|
||||
@@ -49,18 +49,39 @@ linters:
|
||||
- gocritic
|
||||
- depguard
|
||||
- forbidigo
|
||||
- path-except: (shortcuts/|internal/)
|
||||
# Paths that run forbidigo. Add an entry when a path joins one of
|
||||
# the rules below.
|
||||
- path-except: (shortcuts/|internal/|cmd/auth/|cmd/config/|cmd/service/)
|
||||
linters:
|
||||
- forbidigo
|
||||
- path: internal/vfs/
|
||||
linters:
|
||||
- forbidigo
|
||||
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
||||
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
||||
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||
# for the client / credential layer.
|
||||
- path-except: shortcuts/
|
||||
text: shortcuts-no-raw-http
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
|
||||
# Add a path when its migration is complete.
|
||||
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
|
||||
text: errs-typed-only
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-bare-wrap enforced on paths fully migrated to typed final
|
||||
# errors. Scoped separately from errs-typed-only because cmd/auth/,
|
||||
# cmd/config/ still have residual fmt.Errorf and must not be caught.
|
||||
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
|
||||
text: errs-no-bare-wrap
|
||||
linters:
|
||||
- forbidigo
|
||||
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
|
||||
# still used by other domains until their later migration phase.
|
||||
- path-except: (shortcuts/drive/)
|
||||
text: errs-no-legacy-helper
|
||||
linters:
|
||||
- forbidigo
|
||||
|
||||
settings:
|
||||
depguard:
|
||||
@@ -79,6 +100,30 @@ linters:
|
||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||
forbidigo:
|
||||
forbid:
|
||||
# ── legacy output.Err* helpers banned on migrated paths ──
|
||||
# output.ErrBare is intentionally not listed — it is the predicate-
|
||||
# command silent-exit signal, outside the typed envelope contract.
|
||||
- pattern: output\.(ErrValidation|ErrAuth|ErrNetwork|ErrAPI|ErrWithHint|Errorf)\b
|
||||
msg: >-
|
||||
[errs-typed-only] use errs.NewXxxError(...) builder
|
||||
(see errs/types.go).
|
||||
# ── legacy shared error helpers banned on drive ──
|
||||
# These helpers internally produce legacy output.Err* shapes, so they
|
||||
# are invisible to the errs-typed-only ban above. Drive has migrated its
|
||||
# calls to typed errs.* (drive-local driveInputStatError / driveSaveError);
|
||||
# this prevents reintroduction. Other domains still use the shared
|
||||
# helpers (migrated globally in a later phase), so this is drive-scoped.
|
||||
- pattern: (common\.FlagErrorf|common\.WrapInputStatError|common\.WrapSaveErrorByCategory)\b
|
||||
msg: >-
|
||||
[errs-no-legacy-helper] these shared helpers emit legacy output.Err*
|
||||
shapes. Use the typed errs.NewXxxError builders or the drive-local
|
||||
driveInputStatError / driveSaveError helpers (shortcuts/drive/drive_errors.go).
|
||||
# ── bare error wraps banned on fully-typed paths ──
|
||||
- pattern: (fmt\.Errorf|errors\.New)\b
|
||||
msg: >-
|
||||
[errs-no-bare-wrap] final errors must be typed (errs.NewXxxError);
|
||||
wrap a cause with .WithCause(err). Genuine intermediate wraps:
|
||||
//nolint:forbidigo with a reason.
|
||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||
# Bans request / client construction; constants (http.MethodPost,
|
||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -2,6 +2,47 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.46] - 2026-06-02
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add card message format support (#1218)
|
||||
- **im**: Resolve markdown blank-line formatting inconsistency in post messages (#1216)
|
||||
- **vc**: Inline transcript from artifacts API and add keywords (#1206)
|
||||
- **transport**: Add proxy plugin mode for CLI HTTP transport (#1181)
|
||||
- **agent**: Increase agent trace max length to 1024 (#1211)
|
||||
- **shortcuts**: Unconditionally inject `--format` flag for all shortcuts (#1156)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **cli**: Remove FLAGS section from root `--help` (#1226)
|
||||
- **cli**: Stop root `--help` listing per-command flags as global (#1223)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **transport**: Own all HTTP transport in `internal/transport`, fix util layering inversion (#1213)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **base**: Optimize base skill references (#1171)
|
||||
- **drive**: Add Lark Drive knowledge organization workflow (#1028)
|
||||
|
||||
## [v1.0.45] - 2026-06-01
|
||||
|
||||
### Features
|
||||
|
||||
- **errors**: Add typed envelope contract for auth-domain errors (#1135)
|
||||
- **platform**: Support multiple policy rules per plugin (#1182)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **vc**: Add domain boundaries and enrich `+notes` (#1172)
|
||||
- **whiteboard**: Fix whiteboard skill (#1180)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Update login hint and split-flow docs (#1201)
|
||||
|
||||
## [v1.0.44] - 2026-05-29
|
||||
|
||||
### Features
|
||||
@@ -948,6 +989,8 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.46]: https://github.com/larksuite/cli/releases/tag/v1.0.46
|
||||
[v1.0.45]: https://github.com/larksuite/cli/releases/tag/v1.0.45
|
||||
[v1.0.44]: https://github.com/larksuite/cli/releases/tag/v1.0.44
|
||||
[v1.0.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
||||
|
||||
@@ -238,10 +238,10 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
if err != nil {
|
||||
// MarkRaw tells the dispatcher to skip enrichPermissionError so the
|
||||
// raw API error detail (log_id, troubleshooter, permission_violations)
|
||||
// stays on the wire — `lark-cli api` callers explicitly want the raw
|
||||
// envelope.
|
||||
// MarkRaw tells the dispatcher to skip the legacy enrichPermissionError
|
||||
// pass on *output.ExitError values. Typed *errs.* errors that flow
|
||||
// through here keep their canonical message / hint from BuildAPIError;
|
||||
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||
@@ -253,14 +253,14 @@ func apiRun(opts *APIOptions) error {
|
||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
// Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
|
||||
// Per-domain migration in stage 2+ will route through
|
||||
// errclass.BuildAPIError to populate identity-aware fields
|
||||
// (PermissionError.ConsoleURL needs Brand+AppID from the client).
|
||||
// CheckResponse routes through errclass.BuildAPIError for known Lark
|
||||
// codes (typed PermissionError / AuthenticationError / ...). For
|
||||
// unknown codes it falls back to *errs.APIError. The Brand+AppID on
|
||||
// the client populate identity-aware fields (ConsoleURL etc.).
|
||||
CheckError: ac.CheckResponse,
|
||||
})
|
||||
// MarkRaw: see comment above on the DoAPI path. Applies equally to
|
||||
// HandleResponse failures so the raw API error survives to the wire.
|
||||
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
||||
// *ExitError enrichment; typed errors flow through unchanged.
|
||||
if err != nil {
|
||||
return output.MarkRaw(err)
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -670,3 +672,49 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
|
||||
t.Errorf("expected dry-run header, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApiCmd_PermissionError_DerivesFirstClassFields pins that when a Lark
|
||||
// API returns a missing-scope failure, the typed *errs.PermissionError
|
||||
// surfaced by `lark-cli api` lifts the diagnostic signals BuildAPIError
|
||||
// consumed during classification into first-class wire fields
|
||||
// (MissingScopes, LogID, ConsoleURL). The wire shape is the typed envelope
|
||||
// — there is no raw-payload passthrough; new Lark diagnostic fields require
|
||||
// a CLI release.
|
||||
func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test_perm", AppSecret: "secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/docx/v1/documents/test",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991679,
|
||||
"msg": "scope missing",
|
||||
"log_id": "20260527-test-log",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "docx:document"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
cmd := NewCmdApi(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "docx:document" {
|
||||
t.Errorf("MissingScopes = %v, want [docx:document]", pe.MissingScopes)
|
||||
}
|
||||
if pe.LogID != "20260527-test-log" {
|
||||
t.Errorf("LogID = %q, want %q", pe.LogID, "20260527-test-log")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
)
|
||||
|
||||
// NewCmdAuth creates the auth command with subcommands.
|
||||
@@ -70,7 +71,7 @@ func getUserInfo(ctx context.Context, sdk *lark.Client, accessToken string) (ope
|
||||
|
||||
var resp userInfoResponse
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse user info: %v", err)
|
||||
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)
|
||||
@@ -110,6 +111,11 @@ type appInfoResponse struct {
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// getAppInfoFn is the package-level seam used by callers (scopes.go) so tests
|
||||
// can substitute a fake without standing up a full SDK + httpmock pipeline.
|
||||
// Mirrors the pollDeviceToken pattern in login.go.
|
||||
var getAppInfoFn = getAppInfo
|
||||
|
||||
// getAppInfo queries app info from the Lark API.
|
||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
ac, err := f.NewAPIClient()
|
||||
@@ -131,10 +137,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
|
||||
var resp appInfoResponse
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %v", err)
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return nil, fmt.Errorf("API error [%d]: %s", resp.Code, resp.Msg)
|
||||
return nil, classifyAppInfoErr(apiResp.RawBody, resp.Code, resp.Msg, f, appId)
|
||||
}
|
||||
|
||||
app := resp.Data.App
|
||||
@@ -153,3 +159,21 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -318,6 +319,54 @@ func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError pins that when
|
||||
// the Lark API returns a permission code (99991679 with permission_violations),
|
||||
// getAppInfo classifies it as *errs.PermissionError carrying the server-
|
||||
// supplied MissingScopes — not a bare error wrapped as InternalError.
|
||||
func TestAuthScopesRun_LarkPermissionError_TypedAsPermissionError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
tokenResolver := &authScopesTokenResolver{}
|
||||
f.Credential = credential.NewCredentialProvider(nil, nil, tokenResolver, nil)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/application/v6/applications/test-app",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991679,
|
||||
"msg": "scope missing",
|
||||
"error": map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": "application:application:self_manage"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := authScopesRun(&ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "application:application:self_manage" {
|
||||
t.Errorf("MissingScopes = %v, want server-supplied [application:application:self_manage]", pe.MissingScopes)
|
||||
}
|
||||
|
||||
var intErr *errs.InternalError
|
||||
if errors.As(err, &intErr) {
|
||||
t.Error("Lark business error must not be wrapped as InternalError; permission semantics lost")
|
||||
}
|
||||
}
|
||||
|
||||
type authScopesTokenResolver struct {
|
||||
requests []credential.TokenSpec
|
||||
}
|
||||
@@ -389,15 +438,8 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -47,7 +48,7 @@ func authCheckRun(opts *CheckOptions) error {
|
||||
|
||||
required := strings.Fields(opts.Scope)
|
||||
if len(required) == 0 {
|
||||
return output.ErrValidation("--scope cannot be empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be empty").WithParam("--scope")
|
||||
}
|
||||
|
||||
config, err := f.Config()
|
||||
|
||||
167
cmd/auth/check_test.go
Normal file
167
cmd/auth/check_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// `lark-cli auth check` is a predicate command: its README contract is
|
||||
// `exit 0 = ok, 1 = missing`. The JSON answer goes to stdout; stderr stays
|
||||
// empty so callers can write `if lark-cli auth check ...; then ... fi`
|
||||
// without their logs getting polluted by an error envelope on the negative
|
||||
// branch. These tests pin that contract end-to-end through the dispatcher.
|
||||
|
||||
func TestAuthCheckRun_NotLoggedIn_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
// UserOpenId left empty: triggers the not_logged_in branch.
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
||||
|
||||
if got := output.ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("exit code = %d, want 1 (predicate 'missing' signal)", got)
|
||||
}
|
||||
var bare *output.ExitError
|
||||
if !errors.As(err, &bare) {
|
||||
t.Fatalf("expected *output.ExitError (ErrBare), got %T: %v", err, err)
|
||||
}
|
||||
if bare.Detail != nil {
|
||||
t.Errorf("ErrBare must carry no Detail (no envelope), got %+v", bare.Detail)
|
||||
}
|
||||
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty for predicate negative answer, got:\n%s", stderr.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != false {
|
||||
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
||||
}
|
||||
if payload["error"] != "not_logged_in" {
|
||||
t.Errorf("stdout.error = %v, want 'not_logged_in'", payload["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_NoStoredToken_ExitOneWithStdoutOnly(t *testing.T) {
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user", UserName: "tester",
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "calendar:calendar:read"})
|
||||
|
||||
if got := output.ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("exit code = %d, want 1", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty, got:\n%s", stderr.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v", err)
|
||||
}
|
||||
if payload["ok"] != false {
|
||||
t.Errorf("stdout.ok = %v, want false", payload["ok"])
|
||||
}
|
||||
if payload["error"] != "no_token" {
|
||||
t.Errorf("stdout.error = %v, want 'no_token'", payload["error"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_ScopedTokenPresent_ExitZero(t *testing.T) {
|
||||
// Predicate command happy path: stored token covers every required
|
||||
// scope. Exit must be 0 (nil error, not ErrBare), stdout carries the
|
||||
// `{"ok":true,...}` JSON answer, and stderr stays empty so shell
|
||||
// callers can rely on `if lark-cli auth check ...; then` without log
|
||||
// pollution. Pairs with the two exit-1 negatives above so both
|
||||
// branches of the predicate contract are pinned.
|
||||
keyring.MockInit()
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir())
|
||||
|
||||
cfg := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
UserOpenId: "ou_user",
|
||||
UserName: "tester",
|
||||
}
|
||||
now := time.Now()
|
||||
if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{
|
||||
AppId: cfg.AppID,
|
||||
UserOpenId: cfg.UserOpenId,
|
||||
AccessToken: "user-access-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresAt: now.Add(time.Hour).UnixMilli(),
|
||||
RefreshExpiresAt: now.Add(24 * time.Hour).UnixMilli(),
|
||||
GrantedAt: now.Add(-time.Hour).UnixMilli(),
|
||||
Scope: "im:message docx:document",
|
||||
}); err != nil {
|
||||
t.Fatalf("SetStoredToken() error = %v", err)
|
||||
}
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, cfg)
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: "im:message"})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for happy path (exit 0), got %v", err)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != 0 {
|
||||
t.Errorf("exit code = %d, want 0", got)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Errorf("stderr must stay empty for predicate exit-0 answer, got:\n%s", stderr.String())
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
if payload["ok"] != true {
|
||||
t.Errorf("stdout.ok = %v, want true", payload["ok"])
|
||||
}
|
||||
granted, ok := payload["granted"].([]any)
|
||||
if !ok || len(granted) != 1 || granted[0] != "im:message" {
|
||||
t.Errorf("stdout.granted = %v, want [im:message]", payload["granted"])
|
||||
}
|
||||
if payload["missing"] != nil {
|
||||
t.Errorf("stdout.missing = %v, want nil/absent on happy path", payload["missing"])
|
||||
}
|
||||
if _, has := payload["suggestion"]; has {
|
||||
t.Errorf("stdout.suggestion must be absent on happy path; got %v", payload["suggestion"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthCheckRun_EmptyScopeIsValidationError(t *testing.T) {
|
||||
// Scope validation is a real input error, not a predicate negative
|
||||
// answer — it must surface as a typed ValidationError with the normal
|
||||
// stderr envelope, distinct from the silent ErrBare predicate path.
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
err := authCheckRun(&CheckOptions{Factory: f, Scope: " "})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for empty --scope")
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want ExitValidation (%d)", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -54,9 +56,9 @@ run --device-code in a later step after the user confirms authorization. Use 'la
|
||||
to generate QR codes (supports ASCII and PNG formats).`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
||||
"if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"strict mode is %q, user login is disabled in this profile", mode).
|
||||
WithHint("if the user explicitly wants to switch to user identity, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
opts.Ctx = cmd.Context()
|
||||
if runF != nil {
|
||||
@@ -158,14 +160,14 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
for _, d := range selectedDomains {
|
||||
if !knownDomains[d] {
|
||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
||||
return output.ErrValidation("unknown domain %q, did you mean %q?", d, suggestion)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, did you mean %q?", d, suggestion).WithParam("--domain")
|
||||
}
|
||||
available := make([]string, 0, len(knownDomains))
|
||||
for k := range knownDomains {
|
||||
available = append(available, k)
|
||||
}
|
||||
sort.Strings(available)
|
||||
return output.ErrValidation("unknown domain %q, available domains: %s", d, strings.Join(available, ", "))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unknown domain %q, available domains: %s", d, strings.Join(available, ", ")).WithParam("--domain")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,7 +175,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||
|
||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
||||
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--exclude requires --scope, --domain, or --recommend to be specified").WithParam("--exclude")
|
||||
}
|
||||
|
||||
if !hasAnyOption {
|
||||
@@ -183,7 +185,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return output.ErrValidation("no login options selected")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
|
||||
}
|
||||
selectedDomains = result.Domains
|
||||
scopeLevel = result.ScopeLevel
|
||||
@@ -199,7 +201,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
log(msg.HintFooter)
|
||||
log("")
|
||||
log("Note: this command blocks until authorization is complete. For non-streaming agent harnesses, use --no-wait --json, send the verification URL as the final message of the turn, then run --device-code in a later step after the user confirms authorization.")
|
||||
return output.ErrValidation("please specify the scopes to authorize")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "please specify the scopes to authorize").WithParam("--scope")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +230,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
|
||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||
return output.ErrValidation("no matching scopes found, check domain/scope options")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no matching scopes found, check domain/scope options")
|
||||
}
|
||||
|
||||
// Merge --scope additively with the resolved domain scopes.
|
||||
@@ -248,13 +250,13 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
if len(opts.Exclude) > 0 {
|
||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||
if len(unknown) > 0 {
|
||||
return output.ErrValidation(
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"these --exclude scopes are not present in the requested set: %s",
|
||||
strings.Join(unknown, ", "))
|
||||
strings.Join(unknown, ", ")).WithParam("--exclude")
|
||||
}
|
||||
finalScope = excluded
|
||||
if strings.TrimSpace(finalScope) == "" {
|
||||
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no scopes left after applying --exclude; nothing to authorize").WithParam("--exclude")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -265,7 +267,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return output.ErrAuth("device authorization failed: %v", err)
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "device authorization failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// --no-wait: return immediately with device code and URL
|
||||
@@ -277,12 +279,18 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"verification_url": authResp.VerificationUriComplete,
|
||||
"device_code": authResp.DeviceCode,
|
||||
"expires_in": authResp.ExpiresIn,
|
||||
"hint": fmt.Sprintf("**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it.**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it.**Display order:** Output the URL first, then place the QR code image below the URL.**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation. For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. After the user confirms authorization in a later step, run: lark-cli auth login --device-code %s", authResp.DeviceCode),
|
||||
"hint": "**MUST generate QR code AND display it:** You MUST call lark-cli auth qrcode to convert verification_url into a QR code. This is a required step, do NOT skip it. Prefer PNG QR code (--output); use ASCII (--ascii) only when the user explicitly requests it." +
|
||||
"**CRITICAL: You MUST include the QR image in your response.** Generating the file alone is NOT enough—use image tags, inline images, or file attachments to display it." +
|
||||
"**Display order:** Output the URL first, then place the QR code image below the URL." +
|
||||
"**URL Output Rules:** Treat verification_url as an opaque string that cannot be modified. Do NOT URL-encode/decode or add spaces/punctuation." +
|
||||
"For agent harnesses that only deliver final turn messages, make the QR code image (or URL) the final message of the turn and return control to the user; do not block on --device-code in the same turn. **Before ending the turn, tell the user to come back and notify you after completing authorization.**" +
|
||||
"**After the user confirms authorization:** YOU must execute `lark-cli auth login --device-code <device_code>` yourself." +
|
||||
"**Do NOT cache verification_url or device_code for future use.** Always run `lark-cli auth login --no-wait --json` fresh when authorization is needed.",
|
||||
}
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -304,7 +312,7 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(data); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||
@@ -325,25 +333,25 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
"event": "authorization_failed",
|
||||
"error": result.Message,
|
||||
}); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write JSON output: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
|
||||
}
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
||||
}
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
|
||||
}
|
||||
|
||||
// Step 6: Get user info
|
||||
log(msg.AuthSuccess)
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return output.ErrAuth("failed to get SDK: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
|
||||
}
|
||||
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||
if err != nil {
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||
@@ -361,13 +369,13 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
GrantedAt: now,
|
||||
}
|
||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save token: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if issue := ensureRequestedScopesGranted(finalScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
@@ -410,22 +418,22 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
if shouldRemoveLoginRequestedScope(result) {
|
||||
cleanupRequestedScope()
|
||||
}
|
||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
||||
}
|
||||
defer cleanupRequestedScope()
|
||||
if result.Token == nil {
|
||||
return output.ErrAuth("authorization succeeded but no token returned")
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "authorization succeeded but no token returned")
|
||||
}
|
||||
|
||||
// Get user info
|
||||
log(msg.AuthSuccess)
|
||||
sdk, err := f.LarkClient()
|
||||
if err != nil {
|
||||
return output.ErrAuth("failed to get SDK: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to get SDK: %v", err).WithCause(err)
|
||||
}
|
||||
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||
if err != nil {
|
||||
return output.ErrAuth("failed to get user info: %v", err)
|
||||
return errs.NewAuthenticationError(errs.SubtypeUnknown, "failed to get user info: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||
@@ -443,13 +451,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
GrantedAt: now,
|
||||
}
|
||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save token: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save token: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Update config — overwrite Users to single user, clean old tokens
|
||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to update login profile: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to update login profile: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if issue := ensureRequestedScopesGranted(requestedScope, result.Token.Scope, msg, scopeSummary); issue != nil {
|
||||
@@ -464,18 +472,18 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
||||
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||
multi, err := core.LoadMultiAppConfig()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "load config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
app := findProfileByName(multi, profileName)
|
||||
if app == nil {
|
||||
return fmt.Errorf("profile %q not found in config", profileName)
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "profile %q not found in config", profileName)
|
||||
}
|
||||
|
||||
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return fmt.Errorf("save config: %w", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
for _, oldUser := range oldUsers {
|
||||
@@ -519,10 +527,10 @@ func collectScopesForDomains(domains []string, identity string, brand core.LarkB
|
||||
|
||||
// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
continue
|
||||
}
|
||||
if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) {
|
||||
if domainSet[sc.GetService()] && shortcutSupportsIdentity(sc, identity) {
|
||||
for _, s := range sc.DeclaredScopesForIdentity(identity) {
|
||||
scopeSet[s] = true
|
||||
}
|
||||
@@ -549,11 +557,11 @@ func allKnownDomains(brand core.LarkBrand) map[string]bool {
|
||||
}
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
|
||||
if !shortcuts.IsShortcutServiceAvailable(sc.GetService(), brand) {
|
||||
continue
|
||||
}
|
||||
if !registry.HasAuthDomain(sc.Service) {
|
||||
domains[sc.Service] = true
|
||||
if !registry.HasAuthDomain(sc.GetService()) {
|
||||
domains[sc.GetService()] = true
|
||||
}
|
||||
}
|
||||
return domains
|
||||
@@ -572,8 +580,8 @@ func sortedKnownDomains(brand core.LarkBrand) []string {
|
||||
|
||||
// shortcutSupportsIdentity checks if a shortcut supports the given identity ("user" or "bot").
|
||||
// Empty AuthTypes defaults to ["user"].
|
||||
func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
func shortcutSupportsIdentity(sc common.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{"user"}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -63,12 +64,13 @@ func getDomainMetadata(lang string) []domainMeta {
|
||||
shortcutOnlySet[n] = true
|
||||
}
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if !seen[sc.Service] {
|
||||
if shortcutOnlySet[sc.Service] && !registry.HasAuthDomain(sc.Service) {
|
||||
dm := buildDomainMeta(sc.Service, lang)
|
||||
svc := sc.GetService()
|
||||
if !seen[svc] {
|
||||
if shortcutOnlySet[svc] && !registry.HasAuthDomain(svc) {
|
||||
dm := buildDomainMeta(svc, lang)
|
||||
domains = append(domains, dm)
|
||||
}
|
||||
seen[sc.Service] = true
|
||||
seen[svc] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +164,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
|
||||
}
|
||||
|
||||
if len(selectedDomains) == 0 {
|
||||
return nil, output.ErrValidation("no domains selected")
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "no domains selected").WithParam("--domain")
|
||||
}
|
||||
|
||||
// Compute scope summary
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -171,25 +172,12 @@ func handleLoginScopeIssue(opts *LoginOptions, msg *loginMsg, f *cmdutil.Factory
|
||||
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||
return output.ErrBare(output.ExitAuth)
|
||||
}
|
||||
detail := map[string]interface{}{
|
||||
"requested": issue.Summary.Requested,
|
||||
"granted": issue.Summary.Granted,
|
||||
"missing": issue.Summary.Missing,
|
||||
}
|
||||
// Legacy *output.ExitError producer: this literal predates the typed
|
||||
// error contract introduced by errs/. New code MUST NOT construct
|
||||
// *output.ExitError directly — missing-scope signals should move to
|
||||
// *errs.PermissionError (with MissingScopes/ConsoleURL as typed
|
||||
// extension fields) when the login flow migrates to typed errors.
|
||||
return &output.ExitError{
|
||||
Code: output.ExitAuth,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: "missing_scope",
|
||||
Message: issue.Message,
|
||||
Hint: issue.Hint,
|
||||
Detail: detail,
|
||||
},
|
||||
}
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message).
|
||||
WithHint("%s", issue.Hint).
|
||||
WithIdentity("user").
|
||||
WithRequestedScopes(issue.Summary.Requested...).
|
||||
WithGrantedScopes(issue.Summary.Granted...).
|
||||
WithMissingScopes(issue.Summary.Missing...)
|
||||
}
|
||||
|
||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
||||
|
||||
61
cmd/auth/login_result_test.go
Normal file
61
cmd/auth/login_result_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple asserts that the
|
||||
// failed-login JSON branch (loginSucceeded == false, opts.JSON == true) wires
|
||||
// requested + granted + missing scopes into the typed *PermissionError
|
||||
// envelope. Consumers need the full triple to render actionable diagnostics,
|
||||
// not just the missing set.
|
||||
func TestHandleLoginScopeIssue_FailedJSON_PreservesScopeTriple(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
|
||||
requested := []string{"docx:document", "im:message:send"}
|
||||
granted := []string{"docx:document"}
|
||||
missing := []string{"im:message:send"}
|
||||
|
||||
err := handleLoginScopeIssue(
|
||||
&LoginOptions{JSON: true},
|
||||
getLoginMsg("en"),
|
||||
f,
|
||||
&loginScopeIssue{
|
||||
Message: "scope insufficient",
|
||||
Hint: "re-login with --scope im:message:send",
|
||||
Summary: &loginScopeSummary{
|
||||
Requested: requested,
|
||||
Granted: granted,
|
||||
Missing: missing,
|
||||
},
|
||||
},
|
||||
"", // openId empty -> loginSucceeded = false
|
||||
"tester",
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var permErr *errs.PermissionError
|
||||
if !errors.As(err, &permErr) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
|
||||
}
|
||||
if !reflect.DeepEqual(permErr.RequestedScopes, requested) {
|
||||
t.Errorf("RequestedScopes = %v, want %v", permErr.RequestedScopes, requested)
|
||||
}
|
||||
if !reflect.DeepEqual(permErr.GrantedScopes, granted) {
|
||||
t.Errorf("GrantedScopes = %v, want %v", permErr.GrantedScopes, granted)
|
||||
}
|
||||
if !reflect.DeepEqual(permErr.MissingScopes, missing) {
|
||||
t.Errorf("MissingScopes = %v, want %v", permErr.MissingScopes, missing)
|
||||
}
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestNormalizeScopeInput(t *testing.T) {
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
sc := &common.Shortcut{AuthTypes: nil}
|
||||
if !shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected default to support 'user'")
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
|
||||
sc := common.Shortcut{AuthTypes: []string{"user", "bot"}}
|
||||
sc := &common.Shortcut{AuthTypes: []string{"user", "bot"}}
|
||||
if !shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected to support 'user'")
|
||||
}
|
||||
@@ -121,7 +121,7 @@ func TestShortcutSupportsIdentity_ExplicitTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_BotOnly(t *testing.T) {
|
||||
sc := common.Shortcut{AuthTypes: []string{"bot"}}
|
||||
sc := &common.Shortcut{AuthTypes: []string{"bot"}}
|
||||
if shortcutSupportsIdentity(sc, "user") {
|
||||
t.Error("expected bot-only to NOT support 'user'")
|
||||
}
|
||||
@@ -400,12 +400,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -443,12 +442,11 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
||||
Granted: []string{"base:app:copy"},
|
||||
},
|
||||
}, "ou_user", "tester")
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
}
|
||||
|
||||
var data map[string]interface{}
|
||||
@@ -653,12 +651,11 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
}
|
||||
got := stderr.String()
|
||||
for _, want := range []string{
|
||||
@@ -870,6 +867,90 @@ func TestAuthLoginRun_DeviceCodeTokenNilCleansScopeCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty pins the
|
||||
// contract that when --json is set and pollDeviceToken returns OK=false,
|
||||
// stdout carries the structured authorization_failed event and stderr is
|
||||
// NOT polluted with a typed envelope. The returned error is a bare
|
||||
// ExitError with ExitAuth so the dispatcher only propagates the exit code
|
||||
// without emitting a second envelope on top of the JSON event.
|
||||
func TestAuthLoginRun_JSONAbort_StdoutEventOnly_StderrEmpty(t *testing.T) {
|
||||
keyring.MockInit()
|
||||
setupLoginConfigDir(t)
|
||||
|
||||
original := pollDeviceToken
|
||||
t.Cleanup(func() { pollDeviceToken = original })
|
||||
pollDeviceToken = func(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *larkauth.DeviceFlowResult {
|
||||
return &larkauth.DeviceFlowResult{OK: false, Message: "user denied"}
|
||||
}
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
AppID: "cli_test",
|
||||
AppSecret: "secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: larkauth.PathDeviceAuthorization,
|
||||
Body: map[string]interface{}{
|
||||
"device_code": "device-code",
|
||||
"user_code": "user-code",
|
||||
"verification_uri": "https://example.com/verify",
|
||||
"verification_uri_complete": "https://example.com/verify?code=123",
|
||||
"expires_in": 240,
|
||||
"interval": 0,
|
||||
},
|
||||
})
|
||||
|
||||
err := authLoginRun(&LoginOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Scope: "im:message:send",
|
||||
JSON: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for aborted authorization")
|
||||
}
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
}
|
||||
|
||||
// stdout: device_authorization event + authorization_failed event,
|
||||
// the latter carrying the abort message as a structured field.
|
||||
stdoutStr := stdout.String()
|
||||
if !strings.Contains(stdoutStr, `"event":"authorization_failed"`) {
|
||||
t.Errorf("stdout missing authorization_failed event, got: %s", stdoutStr)
|
||||
}
|
||||
if !strings.Contains(stdoutStr, "user denied") {
|
||||
t.Errorf("stdout missing abort message, got: %s", stdoutStr)
|
||||
}
|
||||
|
||||
// stderr must NOT carry a typed envelope: ErrBare propagates the exit
|
||||
// code only, so the dispatcher emits nothing on stderr. The waiting-auth
|
||||
// log line goes through the JSON-mode no-op `log` helper so it is also
|
||||
// suppressed in JSON mode.
|
||||
stderrStr := stderr.String()
|
||||
if strings.Contains(stderrStr, `"type":"authentication"`) {
|
||||
t.Errorf("stderr should not contain typed envelope, got: %s", stderrStr)
|
||||
}
|
||||
if strings.Contains(stderrStr, `"error"`) {
|
||||
t.Errorf("stderr should not contain JSON envelope fields, got: %s", stderrStr)
|
||||
}
|
||||
|
||||
// Returned error must be the bare *output.ExitError signal (no envelope).
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("ExitError.Code = %d, want %d", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
if exitErr.Detail != nil {
|
||||
t.Errorf("ExitError.Detail should be nil for bare signal, got: %+v", exitErr.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
ProfileName: "default",
|
||||
@@ -961,8 +1042,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
||||
"final message of the turn",
|
||||
"return control to the user",
|
||||
"do not block on --device-code in the same turn",
|
||||
"After the user confirms authorization in a later step",
|
||||
"lark-cli auth login --device-code device-code",
|
||||
"come back and notify",
|
||||
"YOU must execute",
|
||||
"lark-cli auth login --device-code <device_code>",
|
||||
"Do NOT cache",
|
||||
"lark-cli auth login --no-wait --json",
|
||||
} {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -60,7 +61,7 @@ func authLogoutRun(opts *LogoutOptions) error {
|
||||
}
|
||||
app.Users = []core.AppUser{}
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||
return nil
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/skip2/go-qrcode"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
@@ -63,7 +63,7 @@ For ASCII output, the result is printed to stdout with fixed size.`,
|
||||
// runQRCode executes the auth qrcode command.
|
||||
func runQRCode(opts *QRCodeOptions) error {
|
||||
if opts.URL == "" {
|
||||
return output.Errorf(output.ExitValidation, "missing_url", "url is required")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "url is required").WithParam("--url")
|
||||
}
|
||||
|
||||
if opts.ASCII {
|
||||
@@ -75,20 +75,20 @@ func runQRCode(opts *QRCodeOptions) error {
|
||||
}
|
||||
|
||||
if opts.Output == "" {
|
||||
return output.Errorf(output.ExitValidation, "missing_output", "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "output file path is required for PNG mode. Use --output or -o flag to specify the output file path.").WithParam("--output")
|
||||
}
|
||||
|
||||
if opts.Size < 32 {
|
||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at least 32, got %d", opts.Size))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at least 32, got %d", opts.Size).WithParam("--size")
|
||||
}
|
||||
|
||||
if opts.Size > 1024 {
|
||||
return output.Errorf(output.ExitValidation, "invalid_size", fmt.Sprintf("size must be at most 1024, got %d", opts.Size))
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "size must be at most 1024, got %d", opts.Size).WithParam("--size")
|
||||
}
|
||||
|
||||
safePath, err := validate.SafeOutputPath(opts.Output)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
||||
@@ -108,7 +108,7 @@ func runQRCode(opts *QRCodeOptions) error {
|
||||
encoder := json.NewEncoder(out)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to write output: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write output: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -118,12 +118,12 @@ func runQRCode(opts *QRCodeOptions) error {
|
||||
func generateImageQRCode(url string, size int, outputPath string) error {
|
||||
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to encode QR code: %v", err))
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to encode QR code: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
err = vfs.WriteFile(outputPath, png, 0644)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "write_error", fmt.Sprintf("failed to write QR code to %s: %v", outputPath, err))
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write QR code to %s: %v", outputPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -133,7 +133,7 @@ func generateImageQRCode(url string, size int, outputPath string) error {
|
||||
func generateASCIIQRCode(url string, w io.Writer) error {
|
||||
q, err := qrcode.New(url, qrcode.Medium)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "encode_error", fmt.Sprintf("failed to create QR code: %v", err))
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to create QR code: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
fmt.Fprint(w, q.ToSmallString(false))
|
||||
|
||||
@@ -5,7 +5,6 @@ package auth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -171,29 +170,15 @@ func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
|
||||
|
||||
func TestRunQRCode_MissingURL(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: ""})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "missing_url" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_url")
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQRCode_MissingOutput(t *testing.T) {
|
||||
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "missing_output" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "missing_output")
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,15 +188,8 @@ func TestRunQRCode_InvalidSize(t *testing.T) {
|
||||
Size: 16,
|
||||
Output: "qr.png",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "invalid_size" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,15 +199,8 @@ func TestRunQRCode_SizeTooLarge(t *testing.T) {
|
||||
Size: 2048,
|
||||
Output: "qr.png",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail.Type != "invalid_size" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "invalid_size")
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,12 +210,8 @@ func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
|
||||
Size: 256,
|
||||
Output: "/etc/passwd",
|
||||
})
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,15 +296,8 @@ func TestGenerateImageQRCode_WriteError(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error writing to nonexistent directory")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitInternal)
|
||||
}
|
||||
if exitErr.Detail.Type != "write_error" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "write_error")
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,11 +318,7 @@ func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty string")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Detail.Type != "encode_error" {
|
||||
t.Errorf("error type = %q, want %q", exitErr.Detail.Type, "encode_error")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -50,11 +51,23 @@ func authScopesRun(opts *ScopesOptions) error {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
|
||||
appInfo, err := getAppInfo(opts.Ctx, f, config.AppID)
|
||||
appInfo, err := getAppInfoFn(opts.Ctx, f, config.AppID)
|
||||
if err != nil {
|
||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||
fmt.Sprintf("failed to get app scope info: %v", err),
|
||||
"ensure the app has enabled the application:application:self_manage scope.")
|
||||
// Discriminate by error type so transport / parse failures are not
|
||||
// reclassified as PermissionError(MissingScope) — re-auth does not
|
||||
// fix network / 5xx / JSON parse errors and misclassifying them
|
||||
// here would mislead agents into re-auth loops.
|
||||
// - typed errors pass through unchanged
|
||||
// - bare errors become InternalError(SubtypeSDKError) with Cause
|
||||
// preserved so callers (errors.Is) can still see the underlying
|
||||
// transport/parse failure.
|
||||
// Genuine permission failures are surfaced from appInfo *content*,
|
||||
// not from this transport-level error path.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||
"failed to get app scope info: %v", err).WithCause(err)
|
||||
}
|
||||
if opts.Format == "pretty" {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
||||
|
||||
121
cmd/auth/scopes_test.go
Normal file
121
cmd/auth/scopes_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubGetAppInfoErr swaps getAppInfoFn for the duration of t so authScopesRun
|
||||
// observes a fixed error from the dependency. t.Cleanup restores the prior
|
||||
// value so tests cannot leak through the package-level seam.
|
||||
func stubGetAppInfoErr(t *testing.T, errToReturn error) {
|
||||
t.Helper()
|
||||
prev := getAppInfoFn
|
||||
getAppInfoFn = func(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||
return nil, errToReturn
|
||||
}
|
||||
t.Cleanup(func() { getAppInfoFn = prev })
|
||||
}
|
||||
|
||||
// scopesTestFactory builds a Factory + ScopesOptions pair sufficient to drive
|
||||
// authScopesRun. Config has a non-empty AppID so we get past the config gate
|
||||
// and reach the getAppInfoFn call.
|
||||
func scopesTestFactory(t *testing.T) *ScopesOptions {
|
||||
t.Helper()
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
return &ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "json",
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
|
||||
// surfaced by the dependency is not re-classified as PermissionError —
|
||||
// re-auth does not fix DNS / transport failures and blanket-wrapping them
|
||||
// would mislead agents into infinite re-auth loops.
|
||||
func TestAuthScopesRun_NetworkErrorPassedThrough(t *testing.T) {
|
||||
netErr := errs.NewNetworkError(errs.SubtypeNetworkDNS, "DNS lookup failed")
|
||||
stubGetAppInfoErr(t, netErr)
|
||||
|
||||
err := authScopesRun(scopesTestFactory(t))
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var permErr *errs.PermissionError
|
||||
if errors.As(err, &permErr) {
|
||||
t.Errorf("network failure must not be classified as PermissionError; got %v", permErr)
|
||||
}
|
||||
var gotNet *errs.NetworkError
|
||||
if !errors.As(err, &gotNet) {
|
||||
t.Fatalf("network failure not preserved through authScopesRun; got %T: %v", err, err)
|
||||
}
|
||||
if gotNet != netErr {
|
||||
t.Errorf("typed network error should pass through identity-stable; got %p, want %p", gotNet, netErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthScopesRun_PermissionErrorPassedThrough pins that typed permission
|
||||
// failures from the dependency also pass through — IsTyped() must not single
|
||||
// out one category.
|
||||
func TestAuthScopesRun_PermissionErrorPassedThrough(t *testing.T) {
|
||||
permErr := errs.NewPermissionError(errs.SubtypeMissingScope, "scope X missing").
|
||||
WithMissingScopes("im:message")
|
||||
stubGetAppInfoErr(t, permErr)
|
||||
|
||||
err := authScopesRun(scopesTestFactory(t))
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var got *errs.PermissionError
|
||||
if !errors.As(err, &got) {
|
||||
t.Fatalf("expected *PermissionError pass-through, got %T: %v", err, err)
|
||||
}
|
||||
if got != permErr {
|
||||
t.Errorf("typed permission error should pass through identity-stable; got %p, want %p", got, permErr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthScopesRun_BareErrorWrappedAsInternal pins the unclassified branch:
|
||||
// a bare error (e.g. json.Unmarshal failure inside getAppInfo) surfaces as
|
||||
// *InternalError{SubtypeSDKError} with the original error preserved on
|
||||
// Cause so errors.Is still walks to it.
|
||||
func TestAuthScopesRun_BareErrorWrappedAsInternal(t *testing.T) {
|
||||
bareErr := fmt.Errorf("failed to parse response: unexpected EOF")
|
||||
stubGetAppInfoErr(t, bareErr)
|
||||
|
||||
err := authScopesRun(scopesTestFactory(t))
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var permErr *errs.PermissionError
|
||||
if errors.As(err, &permErr) {
|
||||
t.Errorf("bare getAppInfo error must not be classified as PermissionError; got %v", permErr)
|
||||
}
|
||||
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(err, &intErr) {
|
||||
t.Fatalf("expected *InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if intErr.Subtype != errs.SubtypeSDKError {
|
||||
t.Errorf("InternalError.Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
||||
}
|
||||
if !errors.Is(err, bareErr) {
|
||||
t.Error("InternalError must carry bareErr via WithCause so errors.Is walks to it")
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/i18n"
|
||||
@@ -181,7 +182,7 @@ type existingBinding struct {
|
||||
func finalizeSource(opts *BindOptions) (string, error) {
|
||||
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
||||
return "", output.ErrValidation("invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --source %q; valid values: openclaw, hermes, lark-channel", explicit).WithParam("--source")
|
||||
}
|
||||
|
||||
var detected string
|
||||
@@ -198,9 +199,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
// before any interactive prompts — running inside Hermes with
|
||||
// --source openclaw (or vice versa) is almost always a mistake.
|
||||
if explicit != "" && detected != "" && explicit != detected {
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
|
||||
"remove --source to auto-detect, or run this command in the correct Agent context")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--source %q does not match detected Agent environment (%s)", explicit, detected).
|
||||
WithHint("remove --source to auto-detect, or run this command in the correct Agent context").
|
||||
WithParam("--source")
|
||||
}
|
||||
|
||||
// TUI: prompt for language before any downstream prompts. The source
|
||||
@@ -228,9 +230,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
||||
if opts.IsTUI {
|
||||
return tuiSelectSource(opts)
|
||||
}
|
||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
||||
"cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
"pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context")
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"cannot determine Agent source: no --source flag and no Agent environment detected").
|
||||
WithHint("pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context").
|
||||
WithParam("--source")
|
||||
}
|
||||
|
||||
// reconcileExistingBinding reads any existing config at configPath and decides
|
||||
@@ -335,8 +338,9 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
||||
return nil
|
||||
}
|
||||
msg := getBindMsg(opts.UILang)
|
||||
return output.ErrWithHint(output.ExitValidation, "bind",
|
||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
||||
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite,
|
||||
"config bind --force", "%s", msg.IdentityEscalationMessage).
|
||||
WithHint("%s", msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
||||
@@ -407,17 +411,14 @@ func commitBinding(opts *BindOptions, appConfig *core.AppConfig, previousConfigB
|
||||
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||
|
||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to create workspace directory: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
||||
}
|
||||
data, err := json.MarshalIndent(multi, "", " ")
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to marshal config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
|
||||
}
|
||||
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "bind",
|
||||
"failed to write config %s: %v", configPath, err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
replaced := previousConfigBytes != nil
|
||||
@@ -628,7 +629,7 @@ func validateBindFlags(opts *BindOptions) error {
|
||||
switch opts.Identity {
|
||||
case "bot-only", "user-default":
|
||||
default:
|
||||
return output.ErrValidation("invalid --identity %q; valid values: bot-only, user-default", opts.Identity)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity")
|
||||
}
|
||||
}
|
||||
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
||||
|
||||
@@ -22,7 +22,9 @@ import (
|
||||
|
||||
// assertExitError checks the full structured error in one assertion. It
|
||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
||||
// typed validation error — they normalize to the same wantDetail fields.
|
||||
// typed errors (ValidationError, ConfigError) — they normalize to the same
|
||||
// wantDetail fields. The wantDetail.Type is matched against the typed error's
|
||||
// Category string ("validation", "config", etc.).
|
||||
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
@@ -52,7 +54,18 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError; error = %v", err, err)
|
||||
var ce *errs.ConfigError
|
||||
if errors.As(err, &ce) {
|
||||
if got := output.ExitCodeOf(err); got != wantCode {
|
||||
t.Errorf("exit code = %d, want %d", got, wantCode)
|
||||
}
|
||||
gotDetail := output.ErrDetail{Type: string(ce.Category), Message: ce.Message, Hint: ce.Hint}
|
||||
if !reflect.DeepEqual(gotDetail, wantDetail) {
|
||||
t.Errorf("config error mismatch:\n got: %+v\n want: %+v", gotDetail, wantDetail)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("error type = %T, want *output.ExitError or *errs.ValidationError / *errs.ConfigError; error = %v", err, err)
|
||||
}
|
||||
|
||||
// assertEnvelope decodes stdout and checks it matches want exactly — every key
|
||||
@@ -370,7 +383,7 @@ func TestConfigBindRun_MissingSourceNonTTY(t *testing.T) {
|
||||
// TestFactory has IsTerminal=false by default
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Type: "validation",
|
||||
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
||||
Hint: "pass --source openclaw|hermes|lark-channel, or run this command inside the corresponding Agent context",
|
||||
})
|
||||
@@ -409,7 +422,7 @@ func TestConfigBindRun_SourceEnvMismatch_OpenClawFlagInHermesEnv(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Type: "validation",
|
||||
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
})
|
||||
@@ -425,7 +438,7 @@ func TestConfigBindRun_SourceEnvMismatch_HermesFlagInOpenClawEnv(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Type: "validation",
|
||||
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
})
|
||||
@@ -553,8 +566,8 @@ func TestConfigBindRun_HermesMissingEnvFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "hermes",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
||||
Hint: "verify Hermes is installed and configured at " + envPath,
|
||||
})
|
||||
@@ -571,8 +584,8 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify OpenClaw is installed and configured",
|
||||
})
|
||||
@@ -719,7 +732,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Type: "validation",
|
||||
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
||||
Hint: "remove --source to auto-detect, or run this command in the correct Agent context",
|
||||
})
|
||||
@@ -737,8 +750,8 @@ func TestConfigBindRun_LarkChannelMissingFile(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "lark-channel",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||
Hint: "verify lark-channel-bridge is installed and configured",
|
||||
})
|
||||
@@ -757,8 +770,8 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "lark-channel",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.id missing in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
})
|
||||
@@ -776,8 +789,8 @@ func TestConfigBindRun_LarkChannelEmptySecret(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "lark-channel",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "accounts.app.secret is empty in " + configPath,
|
||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
||||
})
|
||||
@@ -1128,12 +1141,8 @@ func TestConfigBindRun_OpenClawMultiAccount_MissingAppID(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for multi-account without --app-id, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1179,7 +1188,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
||||
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
||||
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
||||
base := output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Type: "validation",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
}
|
||||
wantWorkFirst := base
|
||||
@@ -1187,20 +1196,17 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
||||
wantPersonalFirst := base
|
||||
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err)
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
|
||||
}
|
||||
if exitErr.Detail == nil {
|
||||
t.Fatal("expected non-nil error detail")
|
||||
}
|
||||
if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) &&
|
||||
!reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) {
|
||||
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
|
||||
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
||||
*exitErr.Detail, wantWorkFirst, wantPersonalFirst)
|
||||
got, wantWorkFirst, wantPersonalFirst)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1225,7 +1231,7 @@ func TestConfigBindRun_OpenClawMultiAccount_WrongAppID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only_one",
|
||||
})
|
||||
@@ -1357,11 +1363,19 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) {
|
||||
Identity: "user-default",
|
||||
})
|
||||
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "bind",
|
||||
Message: msg.IdentityEscalationMessage,
|
||||
Hint: msg.IdentityEscalationHint,
|
||||
})
|
||||
var ce *errs.ConfirmationRequiredError
|
||||
if !errors.As(err, &ce) {
|
||||
t.Fatalf("error type = %T, want *errs.ConfirmationRequiredError; error = %v", err, err)
|
||||
}
|
||||
if ce.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q", ce.Risk, errs.RiskHighRiskWrite)
|
||||
}
|
||||
if ce.Message != msg.IdentityEscalationMessage {
|
||||
t.Errorf("Message mismatch:\ngot: %q\nwant: %q", ce.Message, msg.IdentityEscalationMessage)
|
||||
}
|
||||
if ce.Hint != msg.IdentityEscalationHint {
|
||||
t.Errorf("Hint mismatch:\ngot: %q\nwant: %q", ce.Hint, msg.IdentityEscalationHint)
|
||||
}
|
||||
|
||||
// Config on disk must remain untouched — the gate runs before
|
||||
// commitBinding writes anything.
|
||||
@@ -1522,8 +1536,8 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "hermes",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
})
|
||||
@@ -1542,8 +1556,8 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||
envPath := filepath.Join(hermesHome, ".env")
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "hermes",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||
})
|
||||
@@ -1568,8 +1582,8 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "openclaw.json missing channels.feishu section",
|
||||
Hint: "configure Feishu in OpenClaw first",
|
||||
})
|
||||
@@ -1596,8 +1610,8 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
|
||||
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||
})
|
||||
@@ -1658,8 +1672,8 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
})
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/binding"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
@@ -49,7 +49,7 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
|
||||
case "lark-channel":
|
||||
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
||||
default:
|
||||
return nil, output.ErrValidation("unsupported source: %s", source)
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported source: %s", source).WithParam("--source")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,11 +85,10 @@ func selectCandidate(
|
||||
// from ListCandidates itself and never reach here.
|
||||
switch src {
|
||||
case "openclaw":
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
"no Feishu app configured in openclaw.json",
|
||||
"configure channels.feishu.appId in openclaw.json")
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json").
|
||||
WithHint("configure channels.feishu.appId in openclaw.json")
|
||||
default:
|
||||
return nil, output.ErrValidation("%s: no app configured", src)
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "%s: no app configured", src)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +98,9 @@ func selectCandidate(
|
||||
return &candidates[i], nil
|
||||
}
|
||||
}
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
|
||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase).
|
||||
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
if len(candidates) == 1 {
|
||||
@@ -112,9 +111,9 @@ func selectCandidate(
|
||||
return tuiPrompt(candidates)
|
||||
}
|
||||
|
||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
||||
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
|
||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <id>", cfgBase).
|
||||
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
||||
@@ -149,14 +148,13 @@ func (b *openclawBinder) ConfigPath() string { return b.path }
|
||||
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||
"verify OpenClaw is installed and configured")
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||
WithHint("verify OpenClaw is installed and configured").
|
||||
WithCause(err)
|
||||
}
|
||||
if cfg.Channels.Feishu == nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
"openclaw.json missing channels.feishu section",
|
||||
"configure Feishu in OpenClaw first")
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section").
|
||||
WithHint("configure Feishu in OpenClaw first")
|
||||
}
|
||||
|
||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
||||
@@ -172,8 +170,7 @@ func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||
|
||||
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"internal: Build called before ListCandidates")
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
|
||||
var selected *binding.CandidateApp
|
||||
@@ -184,26 +181,25 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"internal: appID %q not in candidates", appID)
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
|
||||
}
|
||||
|
||||
if selected.AppSecret.IsZero() {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path),
|
||||
"configure channels.feishu.appSecret in openclaw.json")
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
|
||||
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
||||
}
|
||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
|
||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
|
||||
WithHint("check appSecret configuration in %s", b.path).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
@@ -229,15 +225,14 @@ func (b *hermesBinder) ConfigPath() string { return b.path }
|
||||
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||
envMap, err := readDotenv(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("failed to read Hermes config: %v", err),
|
||||
fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err).
|
||||
WithHint("verify Hermes is installed and configured at %s", b.path).
|
||||
WithCause(err)
|
||||
}
|
||||
appID := envMap["FEISHU_APP_ID"]
|
||||
if appID == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
|
||||
"run 'hermes setup' to configure Feishu credentials")
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path).
|
||||
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
b.envMap = envMap
|
||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
||||
@@ -245,24 +240,22 @@ func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||
|
||||
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.envMap == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"internal: Build called before ListCandidates")
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
if b.envMap["FEISHU_APP_ID"] != appID {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"internal: appID %q does not match env", appID)
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID)
|
||||
}
|
||||
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
||||
if appSecret == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
||||
fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path),
|
||||
"run 'hermes setup' to configure Feishu credentials")
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path).
|
||||
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
@@ -290,14 +283,13 @@ func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
||||
"verify lark-channel-bridge is installed and configured")
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||
WithHint("verify lark-channel-bridge is installed and configured").
|
||||
WithCause(err)
|
||||
}
|
||||
if cfg.Accounts.App.ID == "" {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("accounts.app.id missing in %s", b.path),
|
||||
"run lark-channel-bridge's setup to populate the app credential")
|
||||
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path).
|
||||
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
b.cfg = cfg
|
||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
||||
@@ -305,32 +297,30 @@ func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||
|
||||
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||
if b.cfg == nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: Build called before ListCandidates")
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||
}
|
||||
if b.cfg.Accounts.App.ID != appID {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"internal: appID %q does not match config", appID)
|
||||
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID)
|
||||
}
|
||||
if b.cfg.Accounts.App.Secret.IsZero() {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
|
||||
"run lark-channel-bridge's setup to populate the app credential")
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path).
|
||||
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
||||
}
|
||||
|
||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
||||
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||
if err != nil {
|
||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
|
||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
|
||||
WithHint("check appSecret configuration in %s", b.path).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||
if err != nil {
|
||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
||||
"keychain unavailable: %v", err)
|
||||
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||
WithHint("use file: reference in config to bypass keychain").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
return &core.AppConfig{
|
||||
|
||||
@@ -51,8 +51,8 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
||||
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "no Feishu app configured in openclaw.json",
|
||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||
})
|
||||
@@ -64,8 +64,8 @@ func TestSelectCandidate_ZeroCandidates_GenericSource(t *testing.T) {
|
||||
// even before it has a bespoke error message.
|
||||
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "validation",
|
||||
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||
Type: "config",
|
||||
Message: "hermes: no app configured",
|
||||
})
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
})
|
||||
@@ -118,7 +118,7 @@ func TestSelectCandidate_MultiCandidate_NoFlag_NonTUI(t *testing.T) {
|
||||
}
|
||||
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Type: "validation",
|
||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
||||
})
|
||||
@@ -153,7 +153,7 @@ func TestSelectCandidate_SingleCandidate_WrongFlag(t *testing.T) {
|
||||
candidates := []Candidate{{AppID: "cli_only"}}
|
||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||
Type: "openclaw",
|
||||
Type: "validation",
|
||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||
Hint: "available app IDs:\n cli_only",
|
||||
})
|
||||
|
||||
@@ -126,15 +126,11 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "config" || exitErr.Detail.Message != "no active profile" {
|
||||
t.Fatalf("detail = %#v, want config/no active profile", exitErr.Detail)
|
||||
if !strings.Contains(err.Error(), "no active profile") {
|
||||
t.Fatalf("error = %v, want to contain 'no active profile'", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,15 +465,8 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type = %v, want %q", exitErr.Detail, "external_provider")
|
||||
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -41,12 +41,12 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
||||
|
||||
value := args[0]
|
||||
if value != "user" && value != "bot" && value != "auto" {
|
||||
return output.ErrValidation("invalid identity type %q, valid values: user | bot | auto", value)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid identity type %q, valid values: user | bot | auto", value)
|
||||
}
|
||||
|
||||
app.DefaultAs = core.Identity(value)
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||
return nil
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -245,9 +246,29 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
// wrapUpdateExistingProfileErr classifies the error returned by
|
||||
// updateExistingProfileWithoutSecret. Typed errors (e.g. *errs.ValidationError
|
||||
// for blank-input) pass through unchanged so their exit code semantics
|
||||
// survive; legacy *output.ExitError also passes through; everything else
|
||||
// (filesystem, keychain, etc.) is wrapped as InternalError.
|
||||
func wrapUpdateExistingProfileErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||
if existing == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
||||
WithParam("--app-secret")
|
||||
}
|
||||
|
||||
var app *core.AppConfig
|
||||
@@ -255,17 +276,20 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||
app = &existing.Apps[idx]
|
||||
} else {
|
||||
return output.ErrValidation("App Secret cannot be empty for new profile")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
||||
WithParam("--app-secret")
|
||||
}
|
||||
} else {
|
||||
app = existing.CurrentAppConfig("")
|
||||
if app == nil {
|
||||
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new configuration").
|
||||
WithParam("--app-secret")
|
||||
}
|
||||
}
|
||||
|
||||
if app.AppId != appID {
|
||||
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty when changing App ID").
|
||||
WithParam("--app-secret")
|
||||
}
|
||||
|
||||
app.AppId = appID
|
||||
@@ -282,13 +306,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||
if !scanner.Scan() {
|
||||
if err := scanner.Err(); err != nil {
|
||||
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to read secret from stdin: %v", err).WithCause(err)
|
||||
}
|
||||
return output.ErrValidation("stdin is empty, expected app secret")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "stdin is empty, expected app secret")
|
||||
}
|
||||
opts.appSecret = strings.TrimSpace(scanner.Text())
|
||||
if opts.appSecret == "" {
|
||||
return output.ErrValidation("app secret read from stdin is empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "app secret read from stdin is empty")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,7 +324,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// Validate --profile name if set
|
||||
if opts.ProfileName != "" {
|
||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||
return output.ErrValidation("%v", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,14 +333,17 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
brand := parseBrand(opts.Brand)
|
||||
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
||||
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -344,18 +371,21 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return output.ErrValidation("app creation returned no result")
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
||||
}
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -366,7 +396,8 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return err
|
||||
}
|
||||
if result == nil {
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
existing, _ := core.LoadMultiAppConfig()
|
||||
@@ -375,35 +406,36 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
// New secret provided (either from "create" or "existing" with input)
|
||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
} else if result.Mode == "existing" && result.AppID != "" {
|
||||
// Existing app with unchanged secret — update app ID and brand only
|
||||
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
||||
var exitErr *output.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return err
|
||||
}
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
if result.Mode == "existing" {
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
||||
}
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if result.AppSecret != "" {
|
||||
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-terminal: cannot run interactive mode, guide user to --new
|
||||
if !f.IOStreams.IsTerminal {
|
||||
return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
||||
}
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
@@ -431,7 +463,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
appIdInput, err := readLine(prompt)
|
||||
if err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||
}
|
||||
|
||||
prompt = "App Secret"
|
||||
@@ -440,7 +472,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
appSecretInput, err := readLine(prompt)
|
||||
if err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||
}
|
||||
|
||||
prompt = "Brand (lark/feishu)"
|
||||
@@ -451,7 +483,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
brandInput, err := readLine(prompt)
|
||||
if err != nil {
|
||||
return output.ErrValidation("%s", err)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||
}
|
||||
|
||||
resolvedAppId := appIdInput
|
||||
@@ -473,17 +505,23 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
}
|
||||
|
||||
if resolvedAppId == "" || resolvedSecret.IsZero() {
|
||||
return output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||
WithParam("--app-id")
|
||||
}
|
||||
|
||||
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
||||
}
|
||||
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||
printLangPreferenceConfirmation(opts)
|
||||
if appSecretInput != "" {
|
||||
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,16 +6,17 @@ package config
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
qrcode "github.com/skip2/go-qrcode"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
larkauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// configInitResult holds the result of the interactive config init flow.
|
||||
@@ -125,8 +126,16 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
||||
}, nil
|
||||
}
|
||||
|
||||
if appID == "" || appSecret == "" {
|
||||
return nil, output.ErrValidation("App ID and App Secret cannot be empty")
|
||||
switch {
|
||||
case appID == "" && appSecret == "":
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
||||
WithParam("--app-id")
|
||||
case appID == "":
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
|
||||
WithParam("--app-id")
|
||||
case appSecret == "":
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty").
|
||||
WithParam("--app-secret")
|
||||
}
|
||||
|
||||
return &configInitResult{
|
||||
@@ -168,10 +177,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
|
||||
// Step 1: Request app registration (begin)
|
||||
httpClient := &http.Client{}
|
||||
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
||||
// a bypass of proxy plugin mode.
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, output.ErrAuth("app registration failed: %v", err)
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Build and display verification URL + QR code
|
||||
@@ -199,7 +210,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
}
|
||||
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, output.ErrAuth("%v", err)
|
||||
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 4: Handle Lark brand special case
|
||||
@@ -208,12 +219,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
||||
// fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.DetectedLarkTenant)
|
||||
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||
if err != nil {
|
||||
return nil, output.ErrAuth("lark endpoint retry failed: %v", err)
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
||||
}
|
||||
}
|
||||
|
||||
if result.ClientID == "" || result.ClientSecret == "" {
|
||||
return nil, output.ErrAuth("app registration succeeded but missing client_id or client_secret")
|
||||
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
|
||||
}
|
||||
|
||||
// Determine final brand from response
|
||||
|
||||
91
cmd/config/init_probe.go
Normal file
91
cmd/config/init_probe.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
)
|
||||
|
||||
// probeTimeout is the total wall-clock budget for the credential probe step
|
||||
// (covering both TAT acquisition and the subsequent probe request).
|
||||
const probeTimeout = 3 * time.Second
|
||||
|
||||
// runProbe runs a best-effort credential validation after config init has
|
||||
// persisted the App ID and App Secret. It returns a non-nil error only for a
|
||||
// deterministic credential-rejection signal; every other outcome returns nil
|
||||
// so that valid configurations and transient/upstream noise never block the
|
||||
// command.
|
||||
//
|
||||
// The function performs up to two HTTP calls in series, bounded by
|
||||
// probeTimeout:
|
||||
//
|
||||
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
|
||||
// returns a typed errs.* error (via the shared classifyTATResponseCode)
|
||||
// only when the server deterministically rejected the credentials — a
|
||||
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
|
||||
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
|
||||
// so the root dispatcher renders the canonical envelope and `config init`
|
||||
// exits non-zero — identical to how every other token-resolving command
|
||||
// reports the same bad credentials. Ambiguous failures (transport errors,
|
||||
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
|
||||
// errors and are swallowed (return nil), so valid configurations are never
|
||||
// disturbed by upstream noise. errs.IsTyped is the discriminator.
|
||||
//
|
||||
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
|
||||
// that call (success, server error, timeout, parse failure) is always
|
||||
// ignored — return nil regardless.
|
||||
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
|
||||
if factory == nil {
|
||||
return nil
|
||||
}
|
||||
httpClient, err := factory.HttpClient()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, probeTimeout)
|
||||
defer cancel()
|
||||
|
||||
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
|
||||
if err != nil {
|
||||
// A typed error from FetchTAT is a deterministic credential rejection
|
||||
// (classifyTATResponseCode). Propagate it so config init exits with the
|
||||
// same envelope the rest of the CLI uses for bad credentials. Untyped
|
||||
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
|
||||
// silent and let the command succeed.
|
||||
if errs.IsTyped(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TAT succeeded — fire the probe call. Any outcome is ignored.
|
||||
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
|
||||
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
288
cmd/config/init_probe_test.go
Normal file
288
cmd/config/init_probe_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// fakeRT routes requests to per-path handlers and records what it saw.
|
||||
type fakeRT struct {
|
||||
tatHandler func(req *http.Request) (*http.Response, error)
|
||||
probeHandler func(req *http.Request) (*http.Response, error)
|
||||
tatCalls int
|
||||
probeCalls int
|
||||
probeReq *http.Request
|
||||
probeBody string
|
||||
}
|
||||
|
||||
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
switch {
|
||||
case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"):
|
||||
f.tatCalls++
|
||||
if f.tatHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil
|
||||
}
|
||||
return f.tatHandler(req)
|
||||
case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"):
|
||||
f.probeCalls++
|
||||
f.probeReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
f.probeBody = string(b)
|
||||
}
|
||||
if f.probeHandler == nil {
|
||||
return jsonResp(200, `{"code":0,"data":{},"msg":"success"}`), nil
|
||||
}
|
||||
return f.probeHandler(req)
|
||||
}
|
||||
return nil, errors.New("unexpected URL: " + req.URL.String())
|
||||
}
|
||||
|
||||
func jsonResp(code int, body string) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: code,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
}
|
||||
}
|
||||
|
||||
// fakeFactory builds a test Factory whose HttpClient is overridden to use
|
||||
// the caller-supplied RoundTripper.
|
||||
//
|
||||
// Wired through cmdutil.TestFactory(t, nil) so the canonical IOStreams,
|
||||
// Credential, Keychain and FileIO wiring is in place (per repo test-factory
|
||||
// guidance). The HttpClient is then swapped to our stub so we can drive
|
||||
// exact HTTP responses for the probe. Config-dir isolation is set up via
|
||||
// t.Setenv(LARKSUITE_CLI_CONFIG_DIR, t.TempDir()) so any incidental config
|
||||
// touch lands in a temp dir rather than the developer's real config.
|
||||
//
|
||||
// The returned buffer is the Factory's stderr. runProbe never writes to
|
||||
// stderr (it propagates a typed error or stays silent), so every test asserts
|
||||
// this buffer stays empty as an invariant.
|
||||
func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: rt}, nil
|
||||
}
|
||||
return f, errBuf
|
||||
}
|
||||
|
||||
// assertConfigRejection asserts runProbe propagated a deterministic credential
|
||||
// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with
|
||||
// the expected upstream code. This is the same typed error every other
|
||||
// token-resolving command returns for the same bad credentials, and nothing is
|
||||
// written to stderr (the root dispatcher renders the envelope).
|
||||
func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != wantCode {
|
||||
t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// assertSilent asserts runProbe stayed quiet: no propagated error and nothing
|
||||
// written to stderr. Used for every ambiguous (non-credential) outcome.
|
||||
func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Errorf("expected nil (silent), got error: %v", err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("expected no stderr output, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated.
|
||||
func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
|
||||
if rt.probeCalls != 0 {
|
||||
t.Error("probe endpoint must not be called when TAT fails")
|
||||
}
|
||||
assertConfigRejection(t, err, errBuf, 10003)
|
||||
}
|
||||
|
||||
// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta —
|
||||
// the most common real-world rejection, propagated.
|
||||
func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014)
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic rejection and propagates (typed).
|
||||
// An unrecognized code falls back to *errs.APIError via BuildAPIError — still
|
||||
// typed, so the probe still surfaces it rather than swallowing.
|
||||
func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if err == nil || !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a propagated typed error, got %T: %v", err, err)
|
||||
}
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Non-200 HTTP at the TAT endpoint is ambiguous (not a payload credential
|
||||
// rejection) → silent, exit 0.
|
||||
func TestRunProbe_TATHTTPNon200_Silent(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500} {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(code, `nope`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_TATTransportError_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("network down")
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeFails_Silent(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
probeHandler: func(req *http.Request) (*http.Response, error) {
|
||||
return jsonResp(500, `server error`), nil
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.probeCalls != 1 {
|
||||
t.Errorf("probe should be called once, got %d", rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TATSuccess_ProbeOK_Silent(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
if rt.tatCalls != 1 || rt.probeCalls != 1 {
|
||||
t.Errorf("expected 1/1 calls, got tat=%d probe=%d", rt.tatCalls, rt.probeCalls)
|
||||
}
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_ProbeRequestShape(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if rt.probeReq.Method != http.MethodPost {
|
||||
t.Errorf("probe method = %s, want POST", rt.probeReq.Method)
|
||||
}
|
||||
if got := rt.probeReq.URL.String(); got != "https://open.feishu.cn/open-apis/application/v6/larksuite_cli_app/probe" {
|
||||
t.Errorf("probe URL = %s", got)
|
||||
}
|
||||
if got := rt.probeReq.Header.Get("Authorization"); got != "Bearer t-ok" {
|
||||
t.Errorf("Authorization = %q, want Bearer t-ok", got)
|
||||
}
|
||||
if !strings.Contains(rt.probeBody, `"from":"lark-cli/`+build.Version+`"`) {
|
||||
t.Errorf("probe body missing from field: %s", rt.probeBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_LarkBrand_HostRoutedCorrectly(t *testing.T) {
|
||||
rt := &fakeRT{}
|
||||
f, _ := fakeFactory(t, rt)
|
||||
if err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandLark); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if rt.probeReq == nil {
|
||||
t.Fatal("probe request not captured")
|
||||
}
|
||||
if !strings.Contains(rt.probeReq.URL.Host, "larksuite.com") {
|
||||
t.Errorf("probe host = %s, want larksuite.com", rt.probeReq.URL.Host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_HTTPClientError_Silent(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, errBuf, _ := cmdutil.TestFactory(t, nil)
|
||||
f.HttpClient = func() (*http.Client, error) {
|
||||
return nil, errors.New("client init failed")
|
||||
}
|
||||
assertSilent(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf)
|
||||
}
|
||||
|
||||
func TestRunProbe_TimeoutHonored(t *testing.T) {
|
||||
rt := &fakeRT{
|
||||
tatHandler: func(req *http.Request) (*http.Response, error) {
|
||||
<-req.Context().Done()
|
||||
return nil, req.Context().Err()
|
||||
},
|
||||
}
|
||||
f, errBuf := fakeFactory(t, rt)
|
||||
|
||||
start := time.Now()
|
||||
err := runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 4*time.Second {
|
||||
t.Errorf("runProbe took %v, expected <= ~3s", elapsed)
|
||||
}
|
||||
// A timeout is an ambiguous failure (context deadline → untyped), so it
|
||||
// must stay silent and not block.
|
||||
assertSilent(t, err, errBuf)
|
||||
}
|
||||
133
cmd/config/init_test.go
Normal file
133
cmd/config/init_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// updateExistingProfileWithoutSecret guards four blank-input scenarios. Each
|
||||
// must surface as *ValidationError(SubtypeInvalidArgument) per RFC 6749 §5.2:
|
||||
// SubtypeInvalidClient is reserved for IAM rejection of malformed credentials,
|
||||
// not for missing user input.
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_NilConfig_EmitsValidationError(t *testing.T) {
|
||||
err := updateExistingProfileWithoutSecret(nil, "", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_UnknownProfile_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "missing-profile", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_NoCurrentApp_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
CurrentApp: "missing",
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_test", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
func TestUpdateExistingProfileWithoutSecret_AppIdMismatch_EmitsValidationError(t *testing.T) {
|
||||
existing := &core.MultiAppConfig{
|
||||
Apps: []core.AppConfig{{
|
||||
Name: "default",
|
||||
AppId: "app-default",
|
||||
AppSecret: core.PlainSecret("secret-default"),
|
||||
Brand: core.BrandFeishu,
|
||||
}},
|
||||
}
|
||||
err := updateExistingProfileWithoutSecret(existing, "", "cli_different", core.BrandFeishu, "en")
|
||||
assertValidationParam(t, err, "--app-secret")
|
||||
}
|
||||
|
||||
// wrapUpdateExistingProfileErr is the caller-side classifier for the error
|
||||
// returned by updateExistingProfileWithoutSecret. It must preserve typed-error
|
||||
// exit semantics (regression: typed ValidationError was being downgraded to
|
||||
// InternalError by the legacy *output.ExitError-only passthrough).
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_NilPassesThrough(t *testing.T) {
|
||||
if got := wrapUpdateExistingProfileErr(nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_TypedValidationErrorPreserved(t *testing.T) {
|
||||
in := errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty for new profile").
|
||||
WithParam("--app-secret")
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
assertValidationParam(t, got, "--app-secret")
|
||||
// Exit code must remain ExitValidation (2), not ExitInternal (5).
|
||||
if code := output.ExitCodeOf(got); code != output.ExitValidation {
|
||||
t.Errorf("ExitCodeOf = %d, want %d (ExitValidation)", code, output.ExitValidation)
|
||||
}
|
||||
// Must NOT be wrapped as *InternalError.
|
||||
var intErr *errs.InternalError
|
||||
if errors.As(got, &intErr) {
|
||||
t.Errorf("typed ValidationError was downgraded to *InternalError: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_LegacyExitErrorPreserved(t *testing.T) {
|
||||
in := &output.ExitError{Code: 7, Err: errors.New("legacy")}
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError to pass through, got %T: %v", got, got)
|
||||
}
|
||||
if exitErr.Code != 7 {
|
||||
t.Errorf("Code = %d, want 7", exitErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUpdateExistingProfileErr_UntypedErrorBecomesInternal(t *testing.T) {
|
||||
in := fmt.Errorf("disk full")
|
||||
got := wrapUpdateExistingProfileErr(in)
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(got, &intErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T: %v", got, got)
|
||||
}
|
||||
if intErr.Subtype != errs.SubtypeSDKError {
|
||||
t.Errorf("Subtype = %q, want %q", intErr.Subtype, errs.SubtypeSDKError)
|
||||
}
|
||||
}
|
||||
|
||||
// assertValidationParam asserts err is *ValidationError with the given Param.
|
||||
func assertValidationParam(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var valErr *errs.ValidationError
|
||||
if !errors.As(err, &valErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if valErr.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", valErr.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if valErr.Param != wantParam {
|
||||
t.Errorf("Param = %q, want %q", valErr.Param, wantParam)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -53,12 +54,10 @@ func configKeychainDowngradeRun(f *cmdutil.Factory) error {
|
||||
|
||||
result, err := keychain.DowngradeMasterKeyToFile(service)
|
||||
if err != nil {
|
||||
return output.ErrWithHint(
|
||||
output.ExitAPI,
|
||||
"config",
|
||||
fmt.Sprintf("keychain downgrade failed: %v", err),
|
||||
"This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.",
|
||||
)
|
||||
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||
"keychain downgrade failed: %v", err).
|
||||
WithHint("This command must be run from an interactive macOS session (e.g. Terminal.app or iTerm) where the system Keychain is reachable. Running it from inside a sandbox / automation context that blocks Keychain access cannot succeed by design.").
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
switch result {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -21,7 +21,7 @@ func NewCmdConfigKeychainDowngrade(f *cmdutil.Factory) *cobra.Command {
|
||||
Short: "Downgrade keychain storage to a local file (macOS only)",
|
||||
Long: `Downgrade keychain storage to a local file. This subcommand is only supported on macOS; on this platform the keychain layer already uses local files.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return output.ErrValidation("keychain-downgrade is only supported on macOS")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "keychain-downgrade is only supported on macOS")
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
|
||||
@@ -82,8 +82,8 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
||||
"version": p.Version,
|
||||
"capabilities": p.Capabilities,
|
||||
}
|
||||
if p.Rule != nil {
|
||||
entry["rule"] = p.Rule
|
||||
if len(p.Rules) > 0 {
|
||||
entry["rules"] = p.Rules
|
||||
}
|
||||
entry["hooks"] = map[string]any{
|
||||
"observers": p.Observers,
|
||||
|
||||
@@ -59,16 +59,20 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
|
||||
"source_name": sourceName,
|
||||
"denied_paths": active.DeniedPaths,
|
||||
}
|
||||
if active.Rule != nil {
|
||||
out["rule"] = map[string]any{
|
||||
"name": active.Rule.Name,
|
||||
"description": active.Rule.Description,
|
||||
"allow": active.Rule.Allow,
|
||||
"deny": active.Rule.Deny,
|
||||
"max_risk": active.Rule.MaxRisk,
|
||||
"identities": active.Rule.Identities,
|
||||
"allow_unannotated": active.Rule.AllowUnannotated,
|
||||
if len(active.Rules) > 0 {
|
||||
rules := make([]map[string]any, 0, len(active.Rules))
|
||||
for _, r := range active.Rules {
|
||||
rules = append(rules, map[string]any{
|
||||
"name": r.Name,
|
||||
"description": r.Description,
|
||||
"allow": r.Allow,
|
||||
"deny": r.Deny,
|
||||
"max_risk": r.MaxRisk,
|
||||
"identities": r.Identities,
|
||||
"allow_unannotated": r.AllowUnannotated,
|
||||
})
|
||||
}
|
||||
out["rules"] = rules
|
||||
}
|
||||
output.PrintJson(f.IOStreams.Out, out)
|
||||
return nil
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
MaxRisk: "read",
|
||||
}
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: rule,
|
||||
Rules: []*platform.Rule{rule},
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourcePlugin,
|
||||
Name: "secaudit",
|
||||
@@ -83,12 +83,16 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
||||
if got["denied_paths"] != float64(42) {
|
||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
||||
}
|
||||
ruleMap, ok := got["rule"].(map[string]any)
|
||||
rulesAny, ok := got["rules"].([]any)
|
||||
if !ok || len(rulesAny) != 1 {
|
||||
t.Fatalf("rules field missing or wrong shape: %v", got["rules"])
|
||||
}
|
||||
ruleMap, ok := rulesAny[0].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("rule field missing or wrong type")
|
||||
t.Fatalf("rules[0] wrong type")
|
||||
}
|
||||
if ruleMap["name"] != "secaudit" {
|
||||
t.Errorf("rule.name = %v", ruleMap["name"])
|
||||
t.Errorf("rules[0].name = %v", ruleMap["name"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +105,7 @@ func TestConfigPolicyShow_YamlSourceNameIsEmpty(t *testing.T) {
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: &platform.Rule{Name: "my-yaml-rule"},
|
||||
Rules: []*platform.Rule{{Name: "my-yaml-rule"}},
|
||||
Source: cmdpolicy.ResolveSource{
|
||||
Kind: cmdpolicy.SourceYAML,
|
||||
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||
|
||||
@@ -6,6 +6,7 @@ package config
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -42,14 +43,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
||||
|
||||
config, err := core.LoadMultiAppConfig()
|
||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
||||
return output.ErrValidation("not configured yet")
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "not configured yet")
|
||||
}
|
||||
|
||||
// Save empty config first. If this fails, keep secrets and tokens intact so the
|
||||
// existing config can still be retried instead of ending up half-removed.
|
||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Clean up keychain entries for all apps after config is cleared.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -47,14 +48,14 @@ func configShowRun(opts *ConfigShowOptions) error {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
return output.Errorf(output.ExitValidation, "config", "failed to load config: %v", err)
|
||||
return errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to load config: %v", err).WithCause(err)
|
||||
}
|
||||
if config == nil || len(config.Apps) == 0 {
|
||||
return core.NotConfiguredError()
|
||||
}
|
||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||
if app == nil {
|
||||
return output.ErrWithHint(output.ExitValidation, "config", "no active profile", "run: lark-cli profile list")
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
|
||||
}
|
||||
users := "(no logged-in users)"
|
||||
if len(app.Users) > 0 {
|
||||
|
||||
@@ -7,9 +7,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -73,14 +73,14 @@ explicit user confirmation — never run on your own initiative.`,
|
||||
|
||||
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||
if global {
|
||||
return output.ErrValidation("--reset cannot be used with --global")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with --global").WithParam("--reset")
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return output.ErrValidation("--reset cannot be used with a value argument")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reset cannot be used with a value argument").WithParam("--reset")
|
||||
}
|
||||
app.StrictMode = nil
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||
return nil
|
||||
@@ -104,7 +104,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
switch mode {
|
||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||
default:
|
||||
return output.ErrValidation("invalid value %q, valid values: bot | user | off", value)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid value %q, valid values: bot | user | off", value)
|
||||
}
|
||||
|
||||
// Capture the old mode at the SAME scope being changed, so we can warn
|
||||
@@ -144,7 +144,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
||||
}
|
||||
|
||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
||||
return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||
|
||||
@@ -47,8 +47,8 @@ func diagAllKnownDomains() []string {
|
||||
seen[p] = true
|
||||
}
|
||||
for _, s := range shortcuts.AllShortcuts() {
|
||||
if s.Service != "" {
|
||||
seen[s.Service] = true
|
||||
if s.GetService() != "" {
|
||||
seen[s.GetService()] = true
|
||||
}
|
||||
}
|
||||
result := make([]string, 0, len(seen))
|
||||
@@ -94,17 +94,17 @@ func diagBuild(domains []string) diagOutput {
|
||||
}
|
||||
|
||||
for _, sc := range allSC {
|
||||
if sc.Service != domain || !diagShortcutSupportsIdentity(&sc, identity) {
|
||||
if sc.GetService() != domain || !diagShortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
for _, scope := range sc.DeclaredScopesForIdentity(identity) {
|
||||
k := methodKey{domain, "shortcut", sc.Command, scope}
|
||||
k := methodKey{domain, "shortcut", sc.GetCommand(), scope}
|
||||
if e, ok := merged[k]; ok {
|
||||
e.Identity = appendUniq(e.Identity, identity)
|
||||
} else {
|
||||
merged[k] = &diagMethodEntry{
|
||||
Domain: domain, Type: "shortcut",
|
||||
Method: sc.Command,
|
||||
Method: sc.GetCommand(),
|
||||
Scope: scope, Identity: []string{identity},
|
||||
}
|
||||
}
|
||||
@@ -148,11 +148,12 @@ func diagBuild(domains []string) diagOutput {
|
||||
return diagOutput{Methods: methods, Scopes: scopes}
|
||||
}
|
||||
|
||||
func diagShortcutSupportsIdentity(sc *shortcutTypes.Shortcut, identity string) bool {
|
||||
if len(sc.AuthTypes) == 0 {
|
||||
func diagShortcutSupportsIdentity(sc shortcutTypes.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
return identity == "user"
|
||||
}
|
||||
for _, a := range sc.AuthTypes {
|
||||
for _, a := range authTypes {
|
||||
if a == identity {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/identitydiag"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
"github.com/larksuite/cli/internal/update"
|
||||
)
|
||||
|
||||
@@ -152,7 +153,9 @@ func networkChecks(ctx context.Context, opts *DoctorOptions, ep core.Endpoints)
|
||||
}
|
||||
}
|
||||
|
||||
httpClient := &http.Client{}
|
||||
// Use the shared proxy-plugin-aware transport so connectivity checks reflect
|
||||
// the real egress path (and are blocked when proxy plugin fails closed).
|
||||
httpClient := transport.NewHTTPClient(0)
|
||||
mcpURL := ep.MCP + "/mcp"
|
||||
|
||||
type probeResult struct {
|
||||
|
||||
@@ -23,12 +23,8 @@ import (
|
||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
||||
// a need_user_authorization signal AND the current command declares scopes
|
||||
// locally (via shortcut registration or service-method metadata).
|
||||
//
|
||||
// Stage-1: this typed path is dormant — no production code returns a typed
|
||||
// *errs.AuthenticationError. Kept so per-domain stage-2 migrations can plug
|
||||
// in without re-architecting. The active stage-1 path is
|
||||
// enrichMissingScopeError below, which operates on legacy *output.ExitError.
|
||||
// locally (via shortcut registration or service-method metadata). Existing
|
||||
// Hint text is preserved; scopes are appended on a new line.
|
||||
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
if err == nil || f == nil {
|
||||
return
|
||||
@@ -55,12 +51,10 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||
// enrichMissingScopeError appends a "current command requires scope(s): X"
|
||||
// hint to a legacy *output.ExitError when the underlying error carries the
|
||||
// need_user_authorization marker AND the current command declares scopes
|
||||
// locally. Matches pre-PR behaviour byte-for-byte; lives on the legacy
|
||||
// envelope path until per-domain stage-2 typed migration.
|
||||
// locally.
|
||||
//
|
||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface.
|
||||
// Stage-2 typed migration will lift this into AuthenticationError.Hint on
|
||||
// the typed envelope via applyNeedAuthorizationHint and remove this helper.
|
||||
// Deprecated: enrichment for the legacy envelope; the typed path is
|
||||
// applyNeedAuthorizationHint above.
|
||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr == nil || exitErr.Detail == nil {
|
||||
return
|
||||
@@ -111,7 +105,7 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string
|
||||
|
||||
service := cmd.Parent().Name()
|
||||
for _, sc := range shortcuts.AllShortcuts() {
|
||||
if sc.Service != service || sc.Command != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
if sc.GetService() != service || sc.GetCommand() != cmd.Name() || !shortcutSupportsIdentity(sc, identity) {
|
||||
continue
|
||||
}
|
||||
scopes := sc.DeclaredScopesForIdentity(identity)
|
||||
@@ -155,53 +149,13 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
|
||||
if methodMap == nil {
|
||||
return nil
|
||||
}
|
||||
return declaredScopesForMethod(methodMap, identity)
|
||||
}
|
||||
|
||||
// declaredScopesForMethod returns all requiredScopes when present; otherwise it
|
||||
// resolves the single recommended scope from the method's scopes list.
|
||||
func declaredScopesForMethod(method map[string]interface{}, identity string) []string {
|
||||
if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 {
|
||||
return interfaceStrings(requiredRaw)
|
||||
}
|
||||
|
||||
rawScopes, _ := method["scopes"].([]interface{})
|
||||
if len(rawScopes) == 0 {
|
||||
return nil
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(rawScopes, identity)
|
||||
if recommended == "" {
|
||||
for _, raw := range rawScopes {
|
||||
if scope, ok := raw.(string); ok && scope != "" {
|
||||
recommended = scope
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if recommended == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{recommended}
|
||||
}
|
||||
|
||||
// interfaceStrings converts a []interface{} containing strings into a compact
|
||||
// []string, skipping empty or non-string values.
|
||||
func interfaceStrings(values []interface{}) []string {
|
||||
scopes := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
scope, ok := value.(string)
|
||||
if !ok || scope == "" {
|
||||
continue
|
||||
}
|
||||
scopes = append(scopes, scope)
|
||||
}
|
||||
return scopes
|
||||
return registry.DeclaredScopesForMethod(methodMap, identity)
|
||||
}
|
||||
|
||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||
// identity, applying the default user-only behavior when AuthTypes is empty.
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.Shortcut, identity string) bool {
|
||||
authTypes := sc.AuthTypes
|
||||
func shortcutSupportsIdentity(sc shortcutcommon.ShortcutDescriptor, identity string) bool {
|
||||
authTypes := sc.GetAuthTypes()
|
||||
if len(authTypes) == 0 {
|
||||
authTypes = []string{string(core.AsUser)}
|
||||
}
|
||||
|
||||
@@ -36,47 +36,71 @@ const userPolicyFileName = "policy.yml"
|
||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||
// the InstallAll phase; nil/empty is fine.
|
||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||
yamlPath, err := userPolicyPath()
|
||||
if err != nil {
|
||||
// No user home dir means we cannot locate the policy. Treat
|
||||
// the same as "file missing": no pruning, no error. This keeps
|
||||
// non-interactive CI environments (no HOME set) running.
|
||||
yamlPath = ""
|
||||
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
||||
// yaml). When a plugin contributed rules we therefore do NOT even
|
||||
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
||||
// error once a plugin is present, so reading a malformed yaml here
|
||||
// would let an unrelated broken file on the user's machine abort a
|
||||
// plugin-governed binary -- exactly the file the plugin is supposed
|
||||
// to shadow. Skipping the read keeps the shadow contract honest.
|
||||
var (
|
||||
yamlRules []*platform.Rule
|
||||
yamlPath string
|
||||
)
|
||||
if len(pluginRules) == 0 {
|
||||
p, perr := userPolicyPath()
|
||||
if perr != nil {
|
||||
// No user home dir means we cannot locate the policy. Treat
|
||||
// the same as "file missing": no pruning, no error. This keeps
|
||||
// non-interactive CI environments (no HOME set) running.
|
||||
p = ""
|
||||
}
|
||||
yamlPath = p
|
||||
loaded, lerr := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||
if lerr != nil {
|
||||
// Yaml-only failures are fail-OPEN at the caller (warn and
|
||||
// continue), but the active-policy snapshot is process-global
|
||||
// and may still carry data from a previous build in long-lived
|
||||
// embedders / tests. Clear it explicitly so `config policy
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return lerr
|
||||
}
|
||||
yamlRules = loaded
|
||||
}
|
||||
|
||||
yamlRule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||
if err != nil {
|
||||
// Yaml-only failures are fail-OPEN at the caller (warn and
|
||||
// continue), but the active-policy snapshot is process-global
|
||||
// and may still carry data from a previous build in long-lived
|
||||
// embedders / tests. Clear it explicitly so `config policy
|
||||
// show` reports "no policy" instead of a stale rule that
|
||||
// doesn't reflect the current command tree.
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
}
|
||||
|
||||
rule, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: pluginRules,
|
||||
YAMLRule: yamlRule,
|
||||
YAMLRules: yamlRules,
|
||||
YAMLPath: yamlPath,
|
||||
})
|
||||
if err != nil {
|
||||
cmdpolicy.SetActive(nil)
|
||||
return err
|
||||
}
|
||||
if rule == nil {
|
||||
if len(rules) == 0 {
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := cmdpolicy.New(rule)
|
||||
// RuleName attributes a denial to a specific rule in the envelope.
|
||||
// With a single rule that is unambiguous and preserves the legacy
|
||||
// envelope verbatim; with several rules a denial means "no rule
|
||||
// granted it", which has no single owner, so the field is left empty
|
||||
// and reason_code=no_matching_rule carries the meaning instead.
|
||||
ruleName := ""
|
||||
if len(rules) == 1 {
|
||||
ruleName = rules[0].Name
|
||||
}
|
||||
|
||||
engine := cmdpolicy.NewSet(rules)
|
||||
decisions := engine.EvaluateAll(rootCmd)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
|
||||
cmdpolicy.Apply(rootCmd, denied)
|
||||
|
||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||
Rule: rule,
|
||||
Rules: rules,
|
||||
Source: source,
|
||||
DeniedPaths: len(denied),
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
@@ -184,6 +186,39 @@ func TestApplyUserPolicyPruning_malformedYamlReturnsError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// When a plugin contributed rules, a malformed user policy.yml must NOT
|
||||
// abort: plugin rules shadow yaml entirely, so the broken file is never
|
||||
// read. Regression -- previously LoadYAMLPolicy ran first and an
|
||||
// unrelated broken yaml on the user's machine could fatal a
|
||||
// plugin-governed binary (build.go fail-CLOSES on policy errors when a
|
||||
// plugin is present).
|
||||
func TestApplyUserPolicyPruning_pluginRulesSkipBrokenYaml(t *testing.T) {
|
||||
cfgDir := tmpHome(t)
|
||||
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||
writePolicy(t, cfgDir, "::: not yaml :::") // broken on purpose
|
||||
|
||||
pluginRules := []cmdpolicy.PluginRule{
|
||||
{PluginName: "secaudit", Rule: &platform.Rule{
|
||||
Name: "docs-only",
|
||||
Allow: []string{"docs/**"},
|
||||
MaxRisk: "write",
|
||||
}},
|
||||
}
|
||||
root := fakeTree(t)
|
||||
if err := applyUserPolicyPruning(root, pluginRules); err != nil {
|
||||
t.Fatalf("plugin rules must shadow (and skip reading) yaml; broken yaml should not error, got %v", err)
|
||||
}
|
||||
|
||||
// Plugin rule actually applied: im/+send is outside docs/** -> hidden.
|
||||
if send := findLeaf(t, root, "im", "+send"); !send.Hidden {
|
||||
t.Errorf("im/+send should be hidden by plugin rule (not in docs/** allow)")
|
||||
}
|
||||
// docs/+update is within allow and at/below max_risk -> stays visible.
|
||||
if update := findLeaf(t, root, "docs", "+update"); update.Hidden {
|
||||
t.Errorf("docs/+update should remain visible under plugin rule")
|
||||
}
|
||||
}
|
||||
|
||||
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
||||
// Resolve and produces an error. This is the safety contract: a typo in
|
||||
// the rule must not silently lower the pruning bar.
|
||||
|
||||
217
cmd/root.go
217
cmd/root.go
@@ -4,23 +4,22 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/build"
|
||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/hook"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
@@ -49,20 +48,6 @@ EXAMPLES:
|
||||
# Generic API call
|
||||
lark-cli api GET /open-apis/calendar/v4/calendars
|
||||
|
||||
FLAGS:
|
||||
--params <json> URL/query parameters JSON
|
||||
--data <json> request body JSON (POST/PATCH/PUT/DELETE)
|
||||
--as <type> identity type: user | bot
|
||||
--format <fmt> output format: json (default) | ndjson | table | csv | pretty
|
||||
--page-all automatically paginate through all pages
|
||||
--page-size <N> page size (0 = use API default)
|
||||
--page-limit <N> max pages to fetch with --page-all (default: 10, 0 for unlimited)
|
||||
--page-delay <MS> delay in ms between pages (default: 200, only with --page-all)
|
||||
-o, --output <path> output file path for binary responses
|
||||
--jq <expr> jq expression to filter JSON output
|
||||
-q <expr> shorthand for --jq
|
||||
--dry-run print request without executing
|
||||
|
||||
AI AGENT SKILLS:
|
||||
lark-cli pairs with AI agent skills (Claude Code, etc.) that
|
||||
teach the agent Lark API patterns, best practices, and workflows.
|
||||
@@ -201,43 +186,66 @@ func configureFlagCompletions(args []string) {
|
||||
// and returns the process exit code.
|
||||
//
|
||||
// Dispatch order:
|
||||
// 1. *errs.SecurityPolicyError: keeps the legacy custom envelope
|
||||
// (type=auth_error, string code, retryable, challenge_url) and exit 1.
|
||||
// Carve-out from the typed taxonomy — wire migration deferred to a later PR.
|
||||
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError):
|
||||
// render via the typed envelope writer, which lifts extension fields
|
||||
// (missing_scopes, console_url, ...) to the top level. Routed by
|
||||
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
|
||||
// are promoted via errcompat to their typed errs/ counterparts, with the
|
||||
// original preserved in the Cause chain.
|
||||
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
|
||||
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
|
||||
// typed envelope writer, which lifts extension fields (missing_scopes,
|
||||
// console_url, challenge_url, ...) to the top level. Routed by
|
||||
// errs.CategoryOf via ExitCodeOf.
|
||||
// 3. *core.ConfigError + Legacy *output.ExitError: asExitError adapts them
|
||||
// to a legacy envelope; written via WriteErrorEnvelope. Stage-1 keeps
|
||||
// this path so existing wire shapes are preserved byte-for-byte until
|
||||
// per-domain typed migration in stage 2+.
|
||||
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
|
||||
// envelope, written via WriteErrorEnvelope.
|
||||
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
|
||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
errOut := f.IOStreams.ErrOut
|
||||
|
||||
// SecurityPolicyError keeps the legacy custom envelope (string codes,
|
||||
// challenge_url, retryable) and exit code 1 — its wire shape predates the
|
||||
// typed taxonomy and downstream OAuth/policy consumers depend on it.
|
||||
// The taxonomy migration for this category is deferred to a later PR.
|
||||
var spErr *errs.SecurityPolicyError
|
||||
if errors.As(err, &spErr) {
|
||||
writeSecurityPolicyError(errOut, spErr)
|
||||
return 1
|
||||
// Promote legacy error shapes into typed errs/ before envelope marshal.
|
||||
// NeedAuthorizationError check is first because it is the more specific
|
||||
// shape; *core.ConfigError check follows. errors.As preserves the original
|
||||
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
|
||||
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
|
||||
//
|
||||
// Outer-typed short-circuit: if err is already a typed *errs.* error,
|
||||
// skip PromoteXxxError so the producer's Subtype / Hint / extension
|
||||
// fields are not overwritten by a coarser promoted shape derived from a
|
||||
// legacy error buried in its Cause chain. Promotion is only for legacy
|
||||
// untyped entry points.
|
||||
if !isOuterTypedError(err) {
|
||||
var needAuthErr *internalauth.NeedAuthorizationError
|
||||
if errors.As(err, &needAuthErr) {
|
||||
err = errcompat.PromoteAuthError(needAuthErr)
|
||||
} else {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
err = errcompat.PromoteConfigError(cfgErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// *core.ConfigError flows raw to the legacy envelope path in stage 1
|
||||
// (asExitError → output.ErrWithHint). Typed migration via
|
||||
// errcompat.PromoteConfigError happens in stage 2+.
|
||||
|
||||
// When the typed error is a need_user_authorization signal, fold in the
|
||||
// current command's declared scopes as a Hint so the user/AI sees the
|
||||
// concrete scope(s) to re-auth with. The hint is computed on the fly from
|
||||
// local shortcut/service metadata — it never depends on server state.
|
||||
applyNeedAuthorizationHint(f, err)
|
||||
|
||||
// Staged dispatch: capture the typed exit code BEFORE attempting the
|
||||
// envelope write. WriteTypedErrorEnvelope is best-effort on the wire
|
||||
// (partial-write still returns true) so the exit code we read here is
|
||||
// preserved even if stderr is torn — torn stderr must not downgrade
|
||||
// typed exits 3/4/6/10 to the legacy "Error:" path with exit 1.
|
||||
// WriteTypedErrorEnvelope still returns false when err carries no
|
||||
// Problem; in that case we fall through to the legacy bridge below.
|
||||
typedExit := output.ExitCodeOf(err)
|
||||
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||
return output.ExitCodeOf(err)
|
||||
return typedExit
|
||||
}
|
||||
|
||||
// Partial-failure (batch / multi-status): the ok:false result envelope is
|
||||
// already on stdout; set the exit code and write nothing to stderr.
|
||||
var pfErr *output.PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
|
||||
if exitErr := asExitError(err); exitErr != nil {
|
||||
@@ -256,52 +264,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
// writeSecurityPolicyError writes the security-policy-specific JSON envelope.
|
||||
// This wire format intentionally differs from the typed envelope writer: it
|
||||
// uses string codes ("challenge_required"/"access_denied"), a "auth_error"
|
||||
// type literal, and a top-level "retryable" field — the shape OAuth/policy
|
||||
// consumers have been parsing since before the typed taxonomy existed.
|
||||
func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) {
|
||||
var codeStr string
|
||||
switch spErr.Subtype {
|
||||
case errs.SubtypeChallengeRequired:
|
||||
codeStr = "challenge_required"
|
||||
case errs.SubtypeAccessDenied:
|
||||
codeStr = "access_denied"
|
||||
default:
|
||||
codeStr = strconv.Itoa(spErr.Code)
|
||||
}
|
||||
|
||||
errData := map[string]interface{}{
|
||||
"type": "auth_error",
|
||||
"code": codeStr,
|
||||
"message": spErr.Message,
|
||||
"retryable": false,
|
||||
}
|
||||
if spErr.ChallengeURL != "" {
|
||||
errData["challenge_url"] = spErr.ChallengeURL
|
||||
}
|
||||
if spErr.Hint != "" {
|
||||
errData["hint"] = spErr.Hint
|
||||
}
|
||||
|
||||
env := map[string]interface{}{"ok": false, "error": errData}
|
||||
|
||||
buffer := &bytes.Buffer{}
|
||||
encoder := json.NewEncoder(buffer)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
if encErr := encoder.Encode(env); encErr != nil {
|
||||
fmt.Fprintln(w, `{"ok":false,"error":{"type":"internal_error","code":"marshal_error","message":"failed to marshal error"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, buffer.String())
|
||||
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
|
||||
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
|
||||
// to gate PromoteXxxError so a producer's outer typed envelope is never
|
||||
// overwritten by a coarser shape derived from its legacy Cause.
|
||||
func isOuterTypedError(err error) bool {
|
||||
_, ok := err.(errs.TypedError)
|
||||
return ok
|
||||
}
|
||||
|
||||
// asExitError converts known structured error types to *output.ExitError.
|
||||
// Returns nil for unrecognized errors (e.g. cobra flag errors).
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError bridge; removed after typed migration.
|
||||
// Deprecated: legacy *output.ExitError bridge.
|
||||
func asExitError(err error) *output.ExitError {
|
||||
var cfgErr *core.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
@@ -417,65 +392,55 @@ func installTipsHelpFunc(root *cobra.Command) {
|
||||
})
|
||||
}
|
||||
|
||||
// enrichPermissionError adds console_url and improves the hint for legacy
|
||||
// *output.ExitError permission errors. Differentiates between:
|
||||
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the scope
|
||||
// - LarkErrUserScopeInsufficient (99991679) / LarkErrUserNotAuthorized:
|
||||
// user has not authorized the scope → hint to auth login
|
||||
// - default: other permission errors → console + auth-login fallback
|
||||
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
|
||||
// Message + Hint match the per-subtype canonical text produced by the typed
|
||||
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
|
||||
// This guarantees a caller observing the wire envelope cannot tell whether
|
||||
// the error reached the dispatcher via the legacy *ExitError bridge or via
|
||||
// the typed *errs.PermissionError fast path.
|
||||
//
|
||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
|
||||
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
|
||||
// + ConsoleURL on the typed envelope and remove this helper.
|
||||
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
|
||||
// values produced by errclass.BuildAPIError already carry MissingScopes +
|
||||
// ConsoleURL directly.
|
||||
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
|
||||
if exitErr.Detail == nil {
|
||||
return
|
||||
}
|
||||
// Extract required scopes from API error detail (shared helper)
|
||||
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
|
||||
if len(scopes) == 0 {
|
||||
// Only the legacy permission-class envelope types route here. "app_status"
|
||||
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
|
||||
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
|
||||
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
|
||||
return
|
||||
}
|
||||
|
||||
larkCode := exitErr.Detail.Code
|
||||
meta, ok := errclass.LookupCodeMeta(larkCode)
|
||||
if !ok || meta.Category != errs.CategoryAuthorization {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract required scopes from API error detail (shared helper). May be
|
||||
// empty for app-status codes — canonical message + hint still apply.
|
||||
missing := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
|
||||
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Select the recommended (least-privilege) scope
|
||||
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
|
||||
|
||||
// Build admin console URL with the recommended scope
|
||||
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
|
||||
// Reuse the same console URL builder as the typed path so both wire
|
||||
// envelopes carry identical console_url values for the same input.
|
||||
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
|
||||
|
||||
// Clear raw API detail — useful info is now in message/hint/console_url.
|
||||
exitErr.Detail.Detail = nil
|
||||
|
||||
isBot := f.ResolvedIdentity.IsBot()
|
||||
larkCode := exitErr.Detail.Code
|
||||
switch larkCode {
|
||||
case output.LarkErrUserScopeInsufficient, output.LarkErrUserNotAuthorized:
|
||||
exitErr.Detail.Message = fmt.Sprintf("User not authorized: required scope %s [%d]", recommended, larkCode)
|
||||
if isBot {
|
||||
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||
} else {
|
||||
exitErr.Detail.Hint = fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended)
|
||||
}
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
|
||||
case output.LarkErrAppScopeNotEnabled:
|
||||
exitErr.Detail.Message = fmt.Sprintf("App scope not enabled: required scope %s [%d]", recommended, larkCode)
|
||||
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
|
||||
default:
|
||||
exitErr.Detail.Message = fmt.Sprintf("Permission denied: required scope %s [%d]", recommended, larkCode)
|
||||
if isBot {
|
||||
exitErr.Detail.Hint = "enable the scope in developer console (see console_url)"
|
||||
} else {
|
||||
exitErr.Detail.Hint = fmt.Sprintf(
|
||||
"enable scope in console (see console_url), or run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended)
|
||||
}
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
identity := string(f.ResolvedIdentity)
|
||||
if identity == "" {
|
||||
identity = "user"
|
||||
}
|
||||
|
||||
exitErr.Detail.Message = errclass.CanonicalPermissionMessage(meta.Subtype, cfg.AppID, missing, exitErr.Detail.Message)
|
||||
exitErr.Detail.Hint = errclass.PermissionHint(missing, identity, meta.Subtype, consoleURL)
|
||||
exitErr.Detail.ConsoleURL = consoleURL
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
OK: false,
|
||||
Identity: "bot",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Type: "validation",
|
||||
Message: `strict mode is "user", only user-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
@@ -300,7 +300,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Type: "validation",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
@@ -345,7 +345,7 @@ func TestIntegration_StrictModeBot_ProfileOverride_APIExplicitUserReturnsEnvelop
|
||||
OK: false,
|
||||
Identity: "user",
|
||||
Error: &output.ErrDetail{
|
||||
Type: "command_denied",
|
||||
Type: "validation",
|
||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
||||
Hint: "if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)",
|
||||
},
|
||||
|
||||
361
cmd/root_test.go
361
cmd/root_test.go
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
@@ -137,81 +139,96 @@ func TestIsCompletionCommand(t *testing.T) {
|
||||
// TestPromoteConfigError_* lives with the implementation in
|
||||
// internal/errcompat/promote_test.go.
|
||||
|
||||
// TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope pins the carve-out
|
||||
// for *errs.SecurityPolicyError: it does NOT go through the typed envelope
|
||||
// writer. Downstream OAuth/policy consumers parse a wire format that
|
||||
// predates the typed taxonomy and depend on:
|
||||
// - error.type == "auth_error" (not the Category literal "policy")
|
||||
// - error.code is a string ("challenge_required" / "access_denied"), not a number
|
||||
// - error.retryable is present at the top of the error object
|
||||
// - exit code 1 (not ExitContentSafety 6)
|
||||
//
|
||||
// Migration of this category to the typed envelope is deferred to a later PR.
|
||||
func TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope(t *testing.T) {
|
||||
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
|
||||
// *errs.SecurityPolicyError flows through the canonical typed envelope
|
||||
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
|
||||
// top-level identity, exit code 6 — after the dispatcher carve-out is removed.
|
||||
func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
cases := []struct {
|
||||
name string
|
||||
subtype errs.Subtype
|
||||
code int
|
||||
wantCode string
|
||||
}{
|
||||
{"challenge_required", errs.SubtypeChallengeRequired, 21000, "challenge_required"},
|
||||
{"access_denied", errs.SubtypeAccessDenied, 21001, "access_denied"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
t.Run("21000 challenge_required", func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
spErr := &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: tc.subtype,
|
||||
Code: tc.code,
|
||||
Message: "blocked by access policy",
|
||||
Hint: "complete challenge in your browser",
|
||||
},
|
||||
ChallengeURL: "https://example.com/challenge",
|
||||
}
|
||||
spErr := &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: errs.SubtypeChallengeRequired,
|
||||
Code: 21000,
|
||||
Message: "blocked by access policy",
|
||||
Hint: "complete challenge in your browser",
|
||||
},
|
||||
ChallengeURL: "https://example.com/challenge",
|
||||
}
|
||||
|
||||
gotExit := handleRootError(f, spErr)
|
||||
if gotExit != 1 {
|
||||
t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit)
|
||||
}
|
||||
gotExit := handleRootError(f, spErr)
|
||||
if gotExit != int(output.ExitContentSafety) {
|
||||
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
|
||||
}
|
||||
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
||||
}
|
||||
errObj, ok := env["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
|
||||
}
|
||||
if got := errObj["type"]; got != "auth_error" {
|
||||
t.Errorf("error.type = %v, want %q", got, "auth_error")
|
||||
}
|
||||
if got := errObj["code"]; got != tc.wantCode {
|
||||
t.Errorf("error.code = %v (%T), want %q (string)", got, got, tc.wantCode)
|
||||
}
|
||||
if got, ok := errObj["retryable"].(bool); !ok || got {
|
||||
t.Errorf("error.retryable = %v (%T), want false (bool)", errObj["retryable"], errObj["retryable"])
|
||||
}
|
||||
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
|
||||
t.Errorf("error.challenge_url = %v, want challenge url", got)
|
||||
}
|
||||
if got := errObj["hint"]; got != "complete challenge in your browser" {
|
||||
t.Errorf("error.hint = %v, want hint message", got)
|
||||
}
|
||||
// And the typed-only fields must NOT appear on this envelope.
|
||||
for _, leaked := range []string{"subtype", "missing_scopes", "console_url"} {
|
||||
if _, exists := errObj[leaked]; exists {
|
||||
t.Errorf("error.%s leaked into legacy security envelope: %v", leaked, errObj[leaked])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
||||
}
|
||||
errObj, ok := env["error"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
|
||||
}
|
||||
if got := errObj["type"]; got != "policy" {
|
||||
t.Errorf("error.type = %v, want %q", got, "policy")
|
||||
}
|
||||
if got := errObj["subtype"]; got != "challenge_required" {
|
||||
t.Errorf("error.subtype = %v, want %q", got, "challenge_required")
|
||||
}
|
||||
if got, ok := errObj["code"].(float64); !ok || int(got) != 21000 {
|
||||
t.Errorf("error.code = %v (%T), want 21000 (number)", errObj["code"], errObj["code"])
|
||||
}
|
||||
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
|
||||
t.Errorf("error.challenge_url = %v, want challenge url", got)
|
||||
}
|
||||
if got := errObj["hint"]; got != "complete challenge in your browser" {
|
||||
t.Errorf("error.hint = %v, want hint message", got)
|
||||
}
|
||||
if _, exists := errObj["retryable"]; exists {
|
||||
t.Errorf("error.retryable leaked into canonical envelope: %v", errObj["retryable"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("21001 access_denied", func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
spErr := &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryPolicy,
|
||||
Subtype: errs.SubtypeAccessDenied,
|
||||
Code: 21001,
|
||||
Message: "access denied",
|
||||
},
|
||||
}
|
||||
|
||||
gotExit := handleRootError(f, spErr)
|
||||
if gotExit != int(output.ExitContentSafety) {
|
||||
t.Errorf("exit code = %d, want %d", gotExit, output.ExitContentSafety)
|
||||
}
|
||||
|
||||
var env map[string]any
|
||||
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
||||
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
||||
}
|
||||
errObj := env["error"].(map[string]any)
|
||||
if got := errObj["type"]; got != "policy" {
|
||||
t.Errorf("error.type = %v, want %q", got, "policy")
|
||||
}
|
||||
if got := errObj["subtype"]; got != "access_denied" {
|
||||
t.Errorf("error.subtype = %v, want %q", got, "access_denied")
|
||||
}
|
||||
if got, ok := errObj["code"].(float64); !ok || int(got) != 21001 {
|
||||
t.Errorf("error.code = %v, want 21001 (number)", errObj["code"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newAuthErrorWithNeedAuthMarker builds a typed *errs.AuthenticationError whose Message
|
||||
@@ -230,6 +247,77 @@ func newAuthErrorWithNeedAuthMarker() *errs.AuthenticationError {
|
||||
}
|
||||
}
|
||||
|
||||
// failingWriter writes up to limit bytes then returns io.ErrShortWrite on
|
||||
// the write that would push past the limit. Used to simulate a stderr that
|
||||
// dies mid-envelope.
|
||||
type failingWriter struct {
|
||||
limit int
|
||||
n int
|
||||
}
|
||||
|
||||
func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
if f.n+len(p) > f.limit {
|
||||
canWrite := f.limit - f.n
|
||||
if canWrite < 0 {
|
||||
canWrite = 0
|
||||
}
|
||||
f.n += canWrite
|
||||
return canWrite, io.ErrShortWrite
|
||||
}
|
||||
f.n += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestHandleRootError_PartialWritePreservesExitCode pins that when the
|
||||
// stderr write fails mid-envelope, handleRootError still returns the typed
|
||||
// exit code (ExitAuth=3 for AuthenticationError), not fall through to the
|
||||
// plain "Error:" path with exit 1. ExitCodeOf is computed from the typed
|
||||
// err BEFORE the envelope write so the exit code is preserved even when
|
||||
// the consumer's stderr pipe dies.
|
||||
func TestHandleRootError_PartialWritePreservesExitCode(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
w := &failingWriter{limit: 20}
|
||||
f.IOStreams.ErrOut = w
|
||||
|
||||
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
|
||||
exit := handleRootError(f, err)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (typed exit code preserved despite write failure)", exit, int(output.ExitAuth))
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRootError_TypedOuterShortCircuitsPromote pins that when a typed
|
||||
// *errs.AuthenticationError carries a legacy *NeedAuthorizationError in its
|
||||
// Cause chain, the dispatcher does NOT run PromoteAuthError — doing so
|
||||
// would replace the producer's TokenExpired subtype + custom hint with the
|
||||
// promoted shape's TokenMissing.
|
||||
func TestHandleRootError_TypedOuterShortCircuitsPromote(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
errOut := &bytes.Buffer{}
|
||||
f.IOStreams.ErrOut = errOut
|
||||
|
||||
innerLegacy := &internalauth.NeedAuthorizationError{UserOpenId: "u_123"}
|
||||
outer := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired").
|
||||
WithHint("custom producer hint").
|
||||
WithCause(innerLegacy)
|
||||
|
||||
exit := handleRootError(f, outer)
|
||||
if exit != int(output.ExitAuth) {
|
||||
t.Errorf("exit = %d, want %d (ExitAuth)", exit, int(output.ExitAuth))
|
||||
}
|
||||
got := errOut.String()
|
||||
if !strings.Contains(got, `"subtype": "token_expired"`) {
|
||||
t.Errorf("envelope lost producer Subtype TokenExpired; got %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "custom producer hint") {
|
||||
t.Errorf("envelope lost producer Hint; got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins
|
||||
// that a typed AuthenticationError carrying the need_user_authorization marker gets a
|
||||
// declared-scopes Hint appended when the current command is a registered
|
||||
@@ -357,3 +445,136 @@ func TestApplyNeedAuthorizationHint_AppendsExistingHint(t *testing.T) {
|
||||
t.Errorf("expected appended hint %q, got %q", want, authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichPermissionError_CanonicalConvergence pins that the legacy
|
||||
// *output.ExitError dispatch path produces the same canonical Message + Hint
|
||||
// + ConsoleURL as the typed *errs.PermissionError dispatch path. Both paths
|
||||
// share errclass.CanonicalPermissionMessage / errclass.PermissionHint /
|
||||
// errclass.ConsoleURL — so a wire consumer cannot tell which path produced
|
||||
// the envelope.
|
||||
func TestEnrichPermissionError_CanonicalConvergence(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
larkCode int
|
||||
legacyErrType string
|
||||
wantMsgSubstrs []string
|
||||
wantHintSubstrs []string
|
||||
wantConsoleURL bool
|
||||
wantNoAuthLogin bool // hint must not suggest `auth login`
|
||||
}{
|
||||
{
|
||||
name: "99991672 app_scope_not_applied",
|
||||
larkCode: 99991672,
|
||||
legacyErrType: "permission",
|
||||
wantMsgSubstrs: []string{"access denied", "app cli_test", "drive:drive:read"},
|
||||
wantHintSubstrs: []string{"developer console", "open.feishu.cn"},
|
||||
wantConsoleURL: true,
|
||||
wantNoAuthLogin: true,
|
||||
},
|
||||
{
|
||||
name: "99991679 missing_scope",
|
||||
larkCode: 99991679,
|
||||
legacyErrType: "permission",
|
||||
wantMsgSubstrs: []string{"unauthorized", "user authorization"},
|
||||
wantHintSubstrs: []string{"lark-cli auth login"},
|
||||
},
|
||||
{
|
||||
name: "99991673 app_unavailable",
|
||||
larkCode: 99991673,
|
||||
legacyErrType: "app_status",
|
||||
wantMsgSubstrs: []string{"unauthorized app", "app cli_test", "not properly installed"},
|
||||
wantHintSubstrs: []string{"tenant admin", "install status"},
|
||||
},
|
||||
{
|
||||
name: "99991662 app_disabled",
|
||||
larkCode: 99991662,
|
||||
legacyErrType: "app_status",
|
||||
wantMsgSubstrs: []string{"app cli_test", "not in use", "currently disabled"},
|
||||
wantHintSubstrs: []string{"tenant admin", "re-enable"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
// Mimic the wire shape ErrAPI produces: legacy *ExitError with
|
||||
// Detail.Type populated by ClassifyLarkError, Detail.Detail
|
||||
// carrying the permission_violations block so ExtractRequiredScopes
|
||||
// can recover the missing scope.
|
||||
scopeForDetail := "drive:drive:read"
|
||||
exitErr := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: tc.legacyErrType,
|
||||
Code: tc.larkCode,
|
||||
Message: "upstream raw message — must be replaced",
|
||||
Detail: map[string]interface{}{
|
||||
"permission_violations": []interface{}{
|
||||
map[string]interface{}{"subject": scopeForDetail},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
enrichPermissionError(f, exitErr)
|
||||
|
||||
for _, sub := range tc.wantMsgSubstrs {
|
||||
if !strings.Contains(exitErr.Detail.Message, sub) {
|
||||
t.Errorf("Message %q missing substring %q", exitErr.Detail.Message, sub)
|
||||
}
|
||||
}
|
||||
if exitErr.Detail.Message == "upstream raw message — must be replaced" {
|
||||
t.Errorf("Message must be rewritten to canonical text; got upstream verbatim")
|
||||
}
|
||||
for _, sub := range tc.wantHintSubstrs {
|
||||
if !strings.Contains(exitErr.Detail.Hint, sub) {
|
||||
t.Errorf("Hint %q missing substring %q", exitErr.Detail.Hint, sub)
|
||||
}
|
||||
}
|
||||
if tc.wantNoAuthLogin && strings.Contains(exitErr.Detail.Hint, "auth login") {
|
||||
t.Errorf("Hint must not suggest `auth login` for this subtype; got %q", exitErr.Detail.Hint)
|
||||
}
|
||||
if tc.wantConsoleURL && exitErr.Detail.ConsoleURL == "" {
|
||||
t.Error("ConsoleURL should be populated when missing scopes are present")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEnrichPermissionError_SkipsUnrelatedTypes pins that an ExitError whose
|
||||
// Detail.Type is neither "permission" nor "app_status" is left untouched —
|
||||
// no Message rewrite, no Hint rewrite, no ConsoleURL injection.
|
||||
func TestEnrichPermissionError_SkipsUnrelatedTypes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "cli_test", AppSecret: "s", Brand: core.BrandFeishu,
|
||||
})
|
||||
f.ResolvedIdentity = core.AsUser
|
||||
|
||||
for _, ty := range []string{"api_error", "validation", "rate_limit", "auth"} {
|
||||
exitErr := &output.ExitError{
|
||||
Code: output.ExitAPI,
|
||||
Detail: &output.ErrDetail{
|
||||
Type: ty,
|
||||
Code: 99991400,
|
||||
Message: "untouched",
|
||||
Hint: "original hint",
|
||||
},
|
||||
}
|
||||
enrichPermissionError(f, exitErr)
|
||||
if exitErr.Detail.Message != "untouched" {
|
||||
t.Errorf("type=%q: Message was rewritten unexpectedly: %q", ty, exitErr.Detail.Message)
|
||||
}
|
||||
if exitErr.Detail.Hint != "original hint" {
|
||||
t.Errorf("type=%q: Hint was rewritten unexpectedly: %q", ty, exitErr.Detail.Hint)
|
||||
}
|
||||
if exitErr.Detail.ConsoleURL != "" {
|
||||
t.Errorf("type=%q: ConsoleURL should not be injected; got %q", ty, exitErr.Detail.ConsoleURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"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/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
@@ -222,7 +224,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return output.ErrValidation("--output and --page-all are mutually exclusive")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
@@ -271,12 +273,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
|
||||
// Stage 1: enrich the 99991679 (LarkErrUserScopeInsufficient) response
|
||||
// with a per-method recommended `--scope` hint, matching the pre-PR
|
||||
// behaviour. Per-domain typed migration in stage 2+ will lift this
|
||||
// into PermissionError.MissingScopes / ConsoleURL on the typed
|
||||
// envelope; until then the legacy ExitError envelope is preserved.
|
||||
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
||||
// Scope-insufficient (99991679) and all other Lark API codes route through
|
||||
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
|
||||
// with MissingScopes / Identity / ConsoleURL populated from the response.
|
||||
checkErr := ac.CheckResponse
|
||||
|
||||
if opts.PageAll {
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
||||
@@ -300,51 +300,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
})
|
||||
}
|
||||
|
||||
// scopeAwareChecker returns an error checker that enriches the
|
||||
// LarkErrUserScopeInsufficient (99991679) business error with a
|
||||
// per-method recommended `--scope` hint. All other non-zero codes fall
|
||||
// through to legacy output.ErrAPI (matching pre-PR behaviour). The
|
||||
// identity parameter is accepted to match the client.ResponseOptions
|
||||
// CheckError signature; isBotMode is captured from the enclosing call so
|
||||
// the recommended scope reflects the caller's identity at request time.
|
||||
//
|
||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
|
||||
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
|
||||
// + ConsoleURL on the typed envelope and remove this helper.
|
||||
func scopeAwareChecker(scopes []interface{}, isBotMode bool) func(interface{}, core.Identity) error {
|
||||
return func(result interface{}, _ core.Identity) error {
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok || resultMap == nil {
|
||||
return nil
|
||||
}
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
if code == 0 {
|
||||
return nil
|
||||
}
|
||||
larkCode := int(code)
|
||||
msg := registry.GetStrFromMap(resultMap, "msg")
|
||||
|
||||
if larkCode == output.LarkErrUserScopeInsufficient && len(scopes) > 0 {
|
||||
identity := "user"
|
||||
if isBotMode {
|
||||
identity = "tenant"
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopes, identity)
|
||||
// Stage-1 carve-out: this restores the pre-PR scope-insufficient
|
||||
// enrichment (recommended scope + auth-login hint) on the legacy
|
||||
// envelope. The typed migration in stage 2+ will lift this into
|
||||
// PermissionError.MissingScopes / ConsoleURL on the typed wire.
|
||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||
fmt.Sprintf("insufficient permissions: [%d] %s", larkCode, msg),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended))
|
||||
}
|
||||
|
||||
// Stage-1 carve-out: matches pre-PR behaviour (legacy ExitError +
|
||||
// ClassifyLarkError). Typed migration is stage-2+.
|
||||
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
||||
}
|
||||
}
|
||||
|
||||
// checkServiceScopes pre-checks user scopes before making the API call.
|
||||
func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error {
|
||||
if ctx.Err() != nil {
|
||||
@@ -366,9 +321,7 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
}
|
||||
}
|
||||
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
||||
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -388,9 +341,24 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
||||
}
|
||||
}
|
||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
||||
fmt.Sprintf("insufficient permissions (required scope: %s)", recommended),
|
||||
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", recommended))
|
||||
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
|
||||
}
|
||||
|
||||
// newPreflightMissingScopeError constructs a PermissionError for the local
|
||||
// pre-flight scope check that converges byte-for-byte with the dispatcher's
|
||||
// BuildAPIError path. Uses the canonical helpers in internal/errclass so
|
||||
// Hint and Message stay in lock-step with the server-response classifier.
|
||||
// ConsoleURL is deliberately omitted: the dispatcher only sets it for
|
||||
// SubtypeAppScopeNotApplied (bot-perspective dev-action recovery), and this
|
||||
// pre-flight path is user-perspective SubtypeMissingScope whose recovery is
|
||||
// `lark-cli auth login --scope ...`, not a console deep-link.
|
||||
func newPreflightMissingScopeError(brand, appID, identity string, missing []string) *errs.PermissionError {
|
||||
consoleURL := errclass.ConsoleURL(brand, appID, missing)
|
||||
return errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
|
||||
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(identity)
|
||||
}
|
||||
|
||||
// buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest.
|
||||
@@ -412,7 +380,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if opts.Params == "-" && opts.Data == "-" {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--params and --data cannot both read from stdin (-)")
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--params and --data cannot both read from stdin (-)").WithParam("--params")
|
||||
}
|
||||
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||
if err != nil {
|
||||
@@ -429,13 +397,14 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
}
|
||||
val, ok := params[name]
|
||||
if !ok || util.IsEmptyValue(val) {
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required path parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required path parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
}
|
||||
valStr := fmt.Sprintf("%v", val)
|
||||
if err := validate.ResourceName(valStr, name); err != nil {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("%s", err)
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err)
|
||||
}
|
||||
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||
delete(params, name)
|
||||
@@ -451,9 +420,10 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
required, _ := p["required"].(bool)
|
||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
||||
fmt.Sprintf("missing required query parameter: %s", name),
|
||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"missing required query parameter: %s", name).
|
||||
WithHint("lark-cli schema %s", schemaPath).
|
||||
WithParam(name)
|
||||
}
|
||||
if exists && !util.IsEmptyValue(value) {
|
||||
queryParams[name] = value
|
||||
@@ -488,7 +458,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return client.RawApiRequest{}, nil, err
|
||||
}
|
||||
if _, ok := dataFields.(map[string]any); !ok {
|
||||
return client.RawApiRequest{}, nil, output.ErrValidation("--data must be a JSON object when used with --file")
|
||||
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a JSON object when used with --file").WithParam("--data")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,12 @@ in production? See **Troubleshooting**.
|
||||
6. Wrapping is idempotent: re-wrapping an already-typed error returns it
|
||||
unchanged across the `errors.As` / `errors.Unwrap` chain.
|
||||
7. For the typed-envelope path, exit codes derive from `Category` only
|
||||
via `output.ExitCodeForCategory`. Two stage-1 exceptions:
|
||||
`SecurityPolicyError` always exits `1` (fixed by its legacy envelope),
|
||||
and unmigrated `*output.ExitError` producers carry a hand-set `Code`;
|
||||
both are retired in the legacy-removal stage.
|
||||
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
|
||||
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
|
||||
producers still carry a hand-set `Code` until they finish migrating.
|
||||
`output.ErrBare(code)` is the lone exception: a deliberate
|
||||
predicate-command signal that bypasses the envelope (see
|
||||
**Predicate commands** below).
|
||||
|
||||
## Wire format
|
||||
|
||||
@@ -73,9 +75,11 @@ Typed errors render to **stderr** as one JSON object per process exit:
|
||||
| `error.retryable` | wire-stable | `true` when present; omitted when `false` |
|
||||
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
||||
|
||||
Carve-out: `SecurityPolicyError` keeps the legacy
|
||||
`{type: "auth_error", code: "challenge_required"|"access_denied", ...}`
|
||||
envelope until its consumers migrate. Removal is staged in **Migration**.
|
||||
`SecurityPolicyError` renders through the same typed envelope as every
|
||||
other category. `error.type` is `"policy"`, `error.subtype` is one of
|
||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
|
||||
retired.
|
||||
|
||||
## Categories
|
||||
|
||||
@@ -115,10 +119,11 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
|
||||
│
|
||||
▼
|
||||
cmd/root.go handleRootError dispatches:
|
||||
├─ *errs.SecurityPolicyError → legacy "auth_error" JSON envelope; exit 1
|
||||
├─ output.ErrBare(code) → no envelope (stdout already written); exit = code
|
||||
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
|
||||
├─ *core.ConfigError → asExitError adapts to legacy envelope ↓
|
||||
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
|
||||
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
|
||||
├─ *core.ConfigError → promoted to typed via errcompat ↑
|
||||
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
|
||||
└─ untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
|
||||
```
|
||||
|
||||
@@ -127,6 +132,54 @@ stderr. Untyped errors (including Cobra's "required flag missing" / unknown
|
||||
subcommand messages) print plain text and exit `1` — consumers must
|
||||
tolerate that fallback.
|
||||
|
||||
### Predicate commands (`output.ErrBare`)
|
||||
|
||||
A small class of commands is **predicates**: they answer a yes/no
|
||||
question and signal the answer through the shell exit code so callers
|
||||
can write `if cmd; then ... fi`. `lark-cli auth check` is the canonical
|
||||
example — its `README` contract is `exit 0 = ok, 1 = missing`.
|
||||
|
||||
These commands deliberately:
|
||||
|
||||
1. write a structured JSON answer to **stdout** themselves, and
|
||||
2. return `output.ErrBare(exitCode)` to communicate the exit code to
|
||||
the dispatcher without producing a `stderr` envelope.
|
||||
|
||||
`output.ErrBare` is **not** an error in the typed-envelope sense — it
|
||||
carries no category, subtype, or message. It is a one-bit output-
|
||||
control signal that lives outside the contract for the same reason
|
||||
`grep -q` / `diff` / `systemctl is-active` set non-zero exit codes
|
||||
without printing anything to stderr: pollution of stderr by a
|
||||
predicate's negative answer would break `2>/dev/null` log hygiene in
|
||||
caller scripts.
|
||||
|
||||
New code should not reach for `ErrBare` unless the command is
|
||||
genuinely a predicate. Anything carrying recoverable error content
|
||||
belongs in a typed `*errs.XxxError` — or, for a batch result, in the
|
||||
partial-failure outcome below.
|
||||
|
||||
### Partial failure (batch / multi-status)
|
||||
|
||||
A batch command (e.g. `drive +push` / `+pull` / `+sync`) that processes
|
||||
many items can finish in a third state, neither full success nor a single
|
||||
error: some items succeeded and some failed. Its primary output is the
|
||||
per-item result, so it does **not** belong in a `stderr` error envelope.
|
||||
|
||||
Such a command returns `runtime.OutPartialFailure(data, meta)`, which:
|
||||
|
||||
1. writes the full result to **stdout** as an `ok:false` envelope — the
|
||||
summary and every per-item outcome (succeeded *and* failed) stay
|
||||
machine-readable, exactly as a successful `Out(...)` would carry them,
|
||||
but with `ok` honestly reporting failure; and
|
||||
2. returns `*output.PartialFailureError`, a typed exit signal the
|
||||
dispatcher maps to a non-zero exit code while writing nothing further
|
||||
to `stderr`.
|
||||
|
||||
This is distinct from `ErrBare` (a predicate's one-bit answer) and from a
|
||||
typed `*errs.XxxError` (a `stderr` error envelope): a partial failure is a
|
||||
*result*, reported on stdout, that also failed. Consumers branch on
|
||||
`ok == false` and then read `data.summary` / `data.items[]`.
|
||||
|
||||
## Consumers
|
||||
|
||||
### Go (in-process)
|
||||
@@ -183,17 +236,25 @@ reworded without notice.
|
||||
|
||||
### Quick reference
|
||||
|
||||
The canonical producer surface is the **builder API in `errs/types.go`** (per type: struct + `NewXxxError` + chained `WithX` setters live in one place):
|
||||
each `NewXxxError(subtype, format, args...)` locks `Category` at the
|
||||
constructor name, requires `Subtype` + `Message` positionally, and exposes
|
||||
optional fields via chained `.WithX(...)` setters. Struct literals remain
|
||||
legal for framework dynamic paths (e.g. classifier fanout) but the lint
|
||||
`CheckTypedErrorCompleteness` still requires `Category` + `Subtype` +
|
||||
`Message` on any literal it sees.
|
||||
|
||||
| Situation | Use |
|
||||
|-----------|-----|
|
||||
| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` |
|
||||
| Login required | `&errs.AuthenticationError{...}` |
|
||||
| Bad user input | `errs.NewValidationError(subtype, msg).WithParam("--flag")` |
|
||||
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
|
||||
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
|
||||
| Local config missing | `&errs.ConfigError{...}` |
|
||||
| Transport failure | `&errs.NetworkError{...}` |
|
||||
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
|
||||
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
|
||||
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
|
||||
| SDK / decode bug | `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` |
|
||||
| Policy block | `&errs.SecurityPolicyError{...}` or `&errs.ContentSafetyError{...}` |
|
||||
| Needs `--yes` | `&errs.ConfirmationRequiredError{...}` |
|
||||
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
|
||||
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
|
||||
| Needs `--yes` | `errs.NewConfirmationRequiredError(risk, action, msg)` |
|
||||
|
||||
### Authoring discipline
|
||||
|
||||
@@ -242,8 +303,9 @@ Do not pick exit codes by hand in new typed producers — `ExitCodeForCategory`
|
||||
maps `Category` to the shell code. A new exit-code requirement means a
|
||||
new `Category`, not a one-off override at the call site.
|
||||
|
||||
(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set
|
||||
codes during stage 1.)
|
||||
(Legacy `*output.ExitError` retains hand-set codes until removal;
|
||||
`SecurityPolicyError` retains a hand-set code on main until the framework
|
||||
migration PR retires the carve-out — see **Migration**.)
|
||||
|
||||
#### Split `Message`, `Hint`, and `Cause`
|
||||
|
||||
@@ -265,15 +327,10 @@ do not inline its `.Error()` into `Message`.
|
||||
Conforming:
|
||||
|
||||
```go
|
||||
return &errs.NetworkError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryNetwork,
|
||||
Subtype: errs.SubtypeNetworkTransport,
|
||||
Message: "request to /open-apis failed after 3 retries",
|
||||
Hint: "check connectivity and retry; set --log-level debug if it persists",
|
||||
},
|
||||
Cause: ioErr,
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||
"request to /open-apis failed after 3 retries").
|
||||
WithHint("check connectivity and retry; set --log-level debug if it persists").
|
||||
WithCause(ioErr)
|
||||
```
|
||||
|
||||
Non-conforming:
|
||||
@@ -294,43 +351,51 @@ For positional arguments, use the canonical name without dashes
|
||||
|
||||
### Constructing typed errors
|
||||
|
||||
The minimal struct literal:
|
||||
Prefer the **builder API**. The constructor pins `Category` + `Subtype` +
|
||||
`Message`, the chained setters fill optional fields, and the resulting
|
||||
value retains its concrete `*XxxError` pointer through the chain so
|
||||
type-specific setters remain reachable to the end:
|
||||
|
||||
```go
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: fmt.Sprintf("--data must be a valid JSON object: %v", parseErr),
|
||||
},
|
||||
Param: "--data",
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--data must be a valid JSON object: %v", parseErr).
|
||||
WithParam("--data")
|
||||
```
|
||||
|
||||
Why builder over struct literal:
|
||||
|
||||
- `Category` is locked at the function name — caller cannot mis-specify it
|
||||
- `Subtype` and `Message` are positional arguments — `go build` rejects
|
||||
the call site if either is missing
|
||||
- The chain reads top-down: required identity first, optional fields after
|
||||
- Message is `fmt.Sprintf`-formatted from `(format, args...)`, matching
|
||||
`fmt.Errorf` muscle memory and avoiding a separate `Sprintf` line
|
||||
|
||||
Struct literals remain legal — `CheckTypedErrorCompleteness` continues to
|
||||
enforce `Category` + `Subtype` + `Message` on any literal it sees — and
|
||||
the framework classifier (`internal/errclass/classify.go`) still uses
|
||||
them on the dynamic dispatch path where a `Problem` value is composed
|
||||
once and wrapped per Category branch. Outside that pattern, new code
|
||||
should reach for the builder.
|
||||
|
||||
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
|
||||
remain callable during migration; new code should prefer the struct
|
||||
literal so `Hint`, `Param`, `Cause`, and other extension fields stay
|
||||
available per [Split `Message`, `Hint`, and `Cause`](#split-message-hint-and-cause).
|
||||
remain callable during migration but are `// Deprecated:` — new code goes
|
||||
through the builder.
|
||||
|
||||
#### Shortcut `Execute` walkthrough
|
||||
|
||||
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
|
||||
form is `output.ErrValidation("--duration-minutes must be between 1 and
|
||||
1440")`. The typed migration target:
|
||||
1440")`. The typed migration target (builder form):
|
||||
|
||||
```go
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
duration := runtime.Int("duration-minutes")
|
||||
if duration < 1 || duration > 1440 {
|
||||
return &errs.ValidationError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryValidation,
|
||||
Subtype: errs.SubtypeInvalidArgument,
|
||||
Message: fmt.Sprintf("--duration-minutes must be between 1 and 1440, got %d", duration),
|
||||
Hint: "pass a value in [1, 1440]",
|
||||
},
|
||||
Param: "--duration-minutes",
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--duration-minutes must be between 1 and 1440, got %d", duration).
|
||||
WithHint("pass a value in [1, 1440]").
|
||||
WithParam("--duration-minutes")
|
||||
}
|
||||
|
||||
_, err := runtime.DoAPI(req, opts)
|
||||
@@ -360,7 +425,7 @@ cover the decision:
|
||||
| Source | Decision | Example |
|
||||
|--------|----------|---------|
|
||||
| Helper returned a typed `*errs.*Error` | Return unchanged | `return err` |
|
||||
| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return &errs.ValidationError{Problem: ..., Cause: jsonErr}` |
|
||||
| Helper returned an untyped error tied to user input (`strconv.Atoi`, `json.Unmarshal`, …) | Construct a typed error; put the untyped error in `Cause` | `return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --data: %v", jsonErr).WithCause(jsonErr)` |
|
||||
| SDK call via `runtime.DoAPI` failed | Return unchanged — the framework boundary already wrapped it | `return err` |
|
||||
| Invariant broken (must-not-happen state) | Lift with `errs.WrapInternal`, set a `Message` describing the invariant | `return errs.WrapInternal(fmt.Errorf("identity resolver returned nil: %w", err))` |
|
||||
|
||||
@@ -391,8 +456,11 @@ through `runtime.DoAPI`.
|
||||
|
||||
#### Add a Subtype
|
||||
|
||||
1. Add a constant in `errs/subtypes.go` (framework) or
|
||||
`errs/subtypes_service_<name>.go` (service).
|
||||
1. Add a constant in `errs/subtypes.go` under the right Category block.
|
||||
Subtypes are framework-shared — service-specific Subtypes are an
|
||||
anti-pattern (the wire `code` field already identifies the source
|
||||
service; Subtype encodes cross-service semantics like `not_found`,
|
||||
`quota_exceeded`).
|
||||
2. If it maps from a Lark code, register the mapping in
|
||||
`internal/errclass/codemeta_<service>.go`.
|
||||
3. Add a dispatch test in `internal/errclass/classify_test.go`.
|
||||
@@ -409,10 +477,9 @@ emits a warning to keep them visible.
|
||||
|
||||
Rare; the existing structs cover the 9 Categories with room. If you must:
|
||||
|
||||
1. Add the struct in `errs/types.go` embedding `errs.Problem`, with a
|
||||
nil-receiver-safe `Unwrap()` if it carries `Cause`.
|
||||
1. In `errs/types.go`, add a new section with: the struct embedding `errs.Problem`, a nil-receiver-safe `Unwrap()` if it carries `Cause`, a `NewXxxError(subtype, format, args...)` constructor, and one chained `WithX` setter per extension field.
|
||||
2. Add an `IsXxx` predicate in `errs/predicates.go`.
|
||||
3. Add a wire-format pin in `errs/marshal_test.go`.
|
||||
3. Add a wire-format pin in `errs/marshal_test.go` and a builder-chain pin in `errs/types_builder_test.go`.
|
||||
|
||||
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
|
||||
top-level wire fields are forbidden — per-Subtype data goes into the
|
||||
@@ -448,51 +515,36 @@ will be removed once business migration completes.
|
||||
|
||||
## Migration
|
||||
|
||||
The error-contract refactor lands in stages. This PR is **stage 1**, and
|
||||
its scope is **strictly framework-only**: every production wire shape
|
||||
matches pre-PR byte-for-byte (additive fields only where the legacy slot
|
||||
had no subtype emission). Stage 1 ships infrastructure; behavioural
|
||||
migration of any specific path lives in later stages.
|
||||
**Strategy shift (2026-05-26).** The original plan (`docs/design/errors-refactor/spec.md` v2.12 §9) was a centrally-driven 4-PR rollout — framework → auth domain → multi-pilot → full-repo + legacy removal. That plan is **superseded** by a hybrid model: framework owner ships framework-level hardening (including a typed `*errs.*Error` migration of `internal/**`) as one focused PR; business-domain typed migration is **self-service** via [`docs/errors-guide.md`](../docs/errors-guide.md) and the builder API, with no central sweep timeline.
|
||||
|
||||
Stages:
|
||||
Why the shift: 800+ legacy call sites split across 8+ business domains do not all share a single reviewer's bandwidth, and the contract is now expressive enough that each domain owner can migrate their own code from the guide without coordinating with framework owner.
|
||||
|
||||
1. **Framework slice — this PR.** Ships the `errs/` typed taxonomy,
|
||||
classifier (`internal/errclass`), promotion stub (`internal/errcompat`,
|
||||
passthrough in stage 1), dispatcher hook (`WriteTypedErrorEnvelope`),
|
||||
and six lint guards (forbidigo + five AST checks). Wire shapes
|
||||
preserved byte-for-byte versus pre-PR, with **one intentional semantic
|
||||
fix**: config-class errors (`*core.ConfigError`) now exit `3` instead
|
||||
of `2`, aligning with `ExitCodeForCategory` (config errors share the
|
||||
auth exit slot per the taxonomy). The classifier and promote helpers
|
||||
are *shipped but unused* in production paths — they exist so stage 2+
|
||||
migrations can plug in without re-architecting.
|
||||
2. **`SecurityPolicyError` typed envelope** — replace the legacy
|
||||
`type: "auth_error"` carve-out with the typed shape.
|
||||
3. **Business-domain migration**, one PR per domain in declared order:
|
||||
`task → drive → calendar → im → mail → whiteboard → contact`. Each PR
|
||||
moves the domain's `output.ErrAPI(...)` / `output.ErrAuth(...)` /
|
||||
`output.ErrWithHint(...)` call sites to typed constructors or
|
||||
`BuildAPIError`, removes its Deprecated annotations, and announces the
|
||||
wire change explicitly.
|
||||
4. **Framework-boundary migration**: `client.WrapDoAPIError` and
|
||||
`client.WrapJSONResponseParseError` flip to typed wrap;
|
||||
`client.CheckResponse` adopts `errclass.BuildAPIError`;
|
||||
`internal/client/client.go resolveAccessToken` adopts the typed
|
||||
`NeedAuthorizationError → *errs.AuthenticationError` recognition;
|
||||
`cmd/auth/scopes.go` and `cmd/service/service.go` adopt typed
|
||||
`*errs.PermissionError`; `errcompat.PromoteConfigError` lifts the
|
||||
`Type="config"` (and later `Type="auth"`) branches to typed.
|
||||
5. **Legacy removal** — once `git grep '\*output\.ExitError'` returns no
|
||||
production hits, delete `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`,
|
||||
`ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and
|
||||
`ErrorEnvelope`.
|
||||
### Current state
|
||||
|
||||
During migration, helper assertions accept both shapes (see
|
||||
`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`)
|
||||
so the build stays green domain-by-domain.
|
||||
1. **Framework slice — ✅ shipped (PR #984).** The `errs/` typed taxonomy, classifier (`internal/errclass`), promotion stub (`internal/errcompat`, passthrough), dispatcher hook (`WriteTypedErrorEnvelope`), and the `lint/errscontract` AST guards. Wire shapes preserved byte-for-byte versus pre-PR, with **one intentional semantic fix**: config-class errors (`*core.ConfigError`) now exit `3` instead of `2`, aligning with `ExitCodeForCategory` (config errors share the auth exit slot per the taxonomy). The classifier and promote helpers are *shipped but unused* in production paths — they exist so framework migration can plug in without re-architecting.
|
||||
|
||||
Before / after at a call site (illustrative — actually performed in
|
||||
stage 3):
|
||||
2. **Builder API — ✅ shipped (this branch).** `errs/types.go` adds the canonical producer surface (`errs.NewXxxError(subtype, format, args...).WithX(...)`) for all 10 typed types, alongside each struct declaration. Constructor signature pins `Category` (via function name) and `Subtype` + `Message` (positional), so the producer cannot mis-specify any of the three identity fields. Optional fields chain through `.WithX(...)` setters that preserve the concrete pointer type.
|
||||
|
||||
### Next: framework migration PR (planned)
|
||||
|
||||
A single PR consolidates the work the original §9 spec split across PRs 2–4 — restricted to framework code, no business sweep:
|
||||
|
||||
- **Migrate `internal/**` typed construction to the builder API.** ~16 call sites in `internal/errclass/classify.go` (BuildAPIError fanout), `internal/auth/transport.go` (SecurityPolicy), `internal/auth/uat_client.go`, `internal/errcompat/promote*.go`, `internal/client/client.go`, `internal/client/api_errors.go`.
|
||||
- **Land the framework-side semantic changes** previously scoped to spec §9 PR 2: `SecurityPolicyError` exit `1→6`, `WrapDoAPIError` typed (`*NetworkError` with subtype timeout/tls/dns/server_error/transport, `*InternalError` for JSON-decode), `WrapJSONResponseParseError` typed, `errcompat.PromoteConfigError` real Type routing, `PromoteAuthError` helper + dispatcher wiring, 10 credential Lark codes registered in codeMeta, 99991543 config classification, `resolveAccessToken` typed `*AuthenticationError`, `BuildAPIError` filling `*PermissionError.MissingScopes` / `Identity` / `ConsoleURL`, deletion of `scopeAwareChecker`.
|
||||
- **Add `forbidigo` rule** banning `output.Err*` constructors in `shortcuts/**` and `cmd/**` (mirrors the contract that new business code must use the builder).
|
||||
- **CHANGELOG** lists the resulting ~10 shell-exit-code shifts in one release entry (vs the spec §1 spread of 11 — the remaining one site lives in `task` business code).
|
||||
|
||||
### Business-domain migration (self-service, no central timeline)
|
||||
|
||||
Each business package migrates its own `output.Err*` call sites to the builder when convenient — typically batched within one domain. The guide at [`docs/errors-guide.md`](../docs/errors-guide.md) walks owners through the 8 typical error modes (validation / authorization / authentication / config / network / api / internal / policy) with real `file:line` examples from main. The three-layer extension model (add Subtype / add field / add Category) handles cases the existing taxonomy does not cover.
|
||||
|
||||
Helper assertions accept both shapes during migration (see `shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`) so domain migrations stay green incrementally.
|
||||
|
||||
### Legacy removal
|
||||
|
||||
Deferred until business migration completion approaches the asymptote. `Errorf`, `ErrAPI`, `ErrAuth`, `ErrWithHint`, `ErrBare`, `ClassifyLarkError`, `ErrDetail`, `ExitError`, and `ErrorEnvelope` are `// Deprecated:` today and stay callable. No fixed removal date.
|
||||
|
||||
### Before / after at a call site
|
||||
|
||||
```go
|
||||
// before (legacy)
|
||||
@@ -502,6 +554,16 @@ return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
|
||||
return errclass.BuildAPIError(parsedResp, cc)
|
||||
```
|
||||
|
||||
```go
|
||||
// before (legacy validation)
|
||||
return output.ErrValidation("--duration-minutes must be between 1 and 1440")
|
||||
|
||||
// after (builder)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--duration-minutes must be between 1 and 1440, got %d", duration).
|
||||
WithParam("--duration-minutes")
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Envelope shows `type=api subtype=unknown` for what should be a more
|
||||
|
||||
@@ -55,6 +55,28 @@ func TestPermissionError_MarshalJSON_HasAllWireFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionError_RequestedGrantedMarshal(t *testing.T) {
|
||||
err := NewPermissionError(SubtypeMissingScope, "partial grant").
|
||||
WithRequestedScopes("docx:document", "im:message:send").
|
||||
WithGrantedScopes("docx:document").
|
||||
WithMissingScopes("im:message:send")
|
||||
|
||||
b, e := json.Marshal(err)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
`"requested_scopes":["docx:document","im:message:send"]`,
|
||||
`"granted_scopes":["docx:document"]`,
|
||||
`"missing_scopes":["im:message:send"]`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("envelope missing %s\nactual: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError_MarshalJSON(t *testing.T) {
|
||||
ve := &ValidationError{
|
||||
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
|
||||
@@ -116,33 +138,26 @@ func TestConfigError_MarshalJSON(t *testing.T) {
|
||||
|
||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||
ne := &NetworkError{
|
||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"},
|
||||
CauseKind: "timeout",
|
||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
||||
}
|
||||
b, _ := json.Marshal(ne)
|
||||
s := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"network"`,
|
||||
`"subtype":"transport"`,
|
||||
`"cause":"timeout"`,
|
||||
`"subtype":"timeout"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
|
||||
// CauseKind omitempty when ""
|
||||
ne2 := &NetworkError{Problem: Problem{Category: CategoryNetwork, Message: "x"}}
|
||||
b2, _ := json.Marshal(ne2)
|
||||
if strings.Contains(string(b2), `"cause"`) {
|
||||
t.Errorf("cause should be omitted when empty; got %s", b2)
|
||||
if strings.Contains(s, `"cause"`) {
|
||||
t.Errorf("cause field should no longer be on the wire; got %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIError_MarshalJSON(t *testing.T) {
|
||||
ae := &APIError{
|
||||
Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true},
|
||||
Detail: map[string]any{"raw": "value"},
|
||||
}
|
||||
b, _ := json.Marshal(ae)
|
||||
s := string(b)
|
||||
@@ -151,19 +166,39 @@ func TestAPIError_MarshalJSON(t *testing.T) {
|
||||
`"subtype":"rate_limit"`,
|
||||
`"code":99991400`,
|
||||
`"retryable":true`,
|
||||
`"detail":{`,
|
||||
`"raw":"value"`,
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("missing %q in %s", want, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detail omitempty when nil
|
||||
ae2 := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
|
||||
b2, _ := json.Marshal(ae2)
|
||||
if strings.Contains(string(b2), `"detail"`) {
|
||||
t.Errorf("detail should be omitted when nil; got %s", b2)
|
||||
// TestProblem_MarshalJSON_Troubleshooter pins the upstream Lark API
|
||||
// troubleshooter URL (resp.error.troubleshooter) surfacing on the wire under
|
||||
// "troubleshooter". Carried via Problem so any typed error that embeds it
|
||||
// inherits the field — populated by errclass.BuildAPIError before the
|
||||
// category switch.
|
||||
func TestProblem_MarshalJSON_Troubleshooter(t *testing.T) {
|
||||
ae := &APIError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAPI,
|
||||
Subtype: SubtypeUnknown,
|
||||
Code: 99991400,
|
||||
Message: "x",
|
||||
Troubleshooter: "https://open.feishu.cn/document/troubleshoot/abc",
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(ae)
|
||||
s := string(b)
|
||||
if !strings.Contains(s, `"troubleshooter":"https://open.feishu.cn/document/troubleshoot/abc"`) {
|
||||
t.Errorf("missing troubleshooter in %s", s)
|
||||
}
|
||||
|
||||
// Absent Troubleshooter must omit the wire key.
|
||||
bare := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
|
||||
b2, _ := json.Marshal(bare)
|
||||
if strings.Contains(string(b2), `"troubleshooter"`) {
|
||||
t.Errorf("absent Troubleshooter must omit wire key; got %s", string(b2))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +220,32 @@ func TestSecurityPolicyError_MarshalJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Pin per-Subtype symmetry: SubtypeAccessDenied must serialize the same
|
||||
// envelope shape as SubtypeChallengeRequired so callers can switch on
|
||||
// subtype without conditional field probing. The constructor + builder
|
||||
// path (mirroring how callsites actually construct these) is exercised
|
||||
// here rather than the struct literal, since SubtypeAccessDenied is the
|
||||
// path threaded through cmd/* sites that surface policy-deny outcomes.
|
||||
func TestSecurityPolicyError_MarshalJSON_AccessDenied(t *testing.T) {
|
||||
err := NewSecurityPolicyError(SubtypeAccessDenied, "user denied").
|
||||
WithChallengeURL("https://chal.example/2")
|
||||
|
||||
b, e := json.Marshal(err)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"policy"`,
|
||||
`"subtype":"access_denied"`,
|
||||
`"challenge_url":"https://chal.example/2"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("envelope missing %s\nactual: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentSafetyError_MarshalJSON(t *testing.T) {
|
||||
cse := &ContentSafetyError{
|
||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"},
|
||||
|
||||
@@ -86,3 +86,12 @@ func IsAuthentication(err error) bool { var x *AuthenticationError; return error
|
||||
|
||||
// IsConfig reports whether err is a *ConfigError.
|
||||
func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) }
|
||||
|
||||
// IsTyped reports whether err is or wraps any of the typed *errs.* errors
|
||||
// in this package (i.e. implements the TypedError interface). Used by call
|
||||
// sites that need to pass already-classified errors through unchanged
|
||||
// instead of blanket-rewrapping them as a different category.
|
||||
func IsTyped(err error) bool {
|
||||
var t TypedError
|
||||
return errors.As(err, &t)
|
||||
}
|
||||
|
||||
@@ -14,16 +14,21 @@ package errs
|
||||
// never appears on the wire.
|
||||
// - No DocURL field. PermissionError carries the same intent via its typed
|
||||
// ConsoleURL extension; other typed errors do not link out.
|
||||
// - Troubleshooter is the upstream Lark API's diagnostic URL (resp.error.
|
||||
// troubleshooter). Carried universally so any classified error can surface
|
||||
// it; populated by errclass.BuildAPIError when the upstream response
|
||||
// includes it, otherwise absent.
|
||||
// - Retryable uses omitempty so only `true` is emitted; consumers treat
|
||||
// absence as false.
|
||||
type Problem struct {
|
||||
Category Category `json:"type"`
|
||||
Subtype Subtype `json:"subtype,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
LogID string `json:"log_id,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
Category Category `json:"type"`
|
||||
Subtype Subtype `json:"subtype,omitempty"`
|
||||
Code int `json:"code,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
LogID string `json:"log_id,omitempty"`
|
||||
Troubleshooter string `json:"troubleshooter,omitempty"`
|
||||
Retryable bool `json:"retryable,omitempty"`
|
||||
}
|
||||
|
||||
// Error satisfies the standard `error` interface. A nil receiver is treated
|
||||
|
||||
@@ -12,7 +12,8 @@ const (
|
||||
|
||||
// CategoryValidation subtypes
|
||||
const (
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeInvalidArgument Subtype = "invalid_argument" // user-supplied flag / arg failed validation (gRPC INVALID_ARGUMENT alignment)
|
||||
SubtypeFailedPrecondition Subtype = "failed_precondition" // request is valid but the system/resource state is not in the state required to execute; caller must change state (not retry) — e.g. ambiguous remote mapping (gRPC FAILED_PRECONDITION alignment)
|
||||
)
|
||||
|
||||
// CategoryAuthentication subtypes
|
||||
@@ -34,7 +35,8 @@ const (
|
||||
SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform
|
||||
SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment)
|
||||
SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable
|
||||
SubtypeAppNotInstalled Subtype = "app_not_installed" // app not enabled / not installed in this tenant
|
||||
SubtypeAppDisabled Subtype = "app_disabled" // app currently disabled in this tenant (was installed/enabled before)
|
||||
SubtypePermissionDenied Subtype = "permission_denied" // resource-level permission denial (authenticated but lacks rights for this resource, HTTP 403 / gRPC PERMISSION_DENIED alignment)
|
||||
)
|
||||
|
||||
// CategoryConfig subtypes
|
||||
@@ -46,7 +48,11 @@ const (
|
||||
|
||||
// CategoryNetwork subtypes
|
||||
const (
|
||||
SubtypeNetworkTransport Subtype = "transport" // transport-layer failure (timeout / TLS / DNS / 5xx); see NetworkError.CauseKind
|
||||
SubtypeNetworkTransport Subtype = "transport" // fallback when no more-specific network subtype matches
|
||||
SubtypeNetworkTimeout Subtype = "timeout" // dial / read timeout
|
||||
SubtypeNetworkTLS Subtype = "tls" // TLS handshake / cert failure
|
||||
SubtypeNetworkDNS Subtype = "dns" // DNS resolution failure
|
||||
SubtypeNetworkServer Subtype = "server_error" // upstream HTTP 5xx
|
||||
)
|
||||
|
||||
// CategoryAPI subtypes
|
||||
@@ -57,6 +63,10 @@ const (
|
||||
SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported)
|
||||
SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request
|
||||
SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner
|
||||
SubtypeNotFound Subtype = "not_found" // referenced resource does not exist (HTTP 404 alignment)
|
||||
SubtypeServerError Subtype = "server_error" // upstream server-side transient error (HTTP 5xx alignment, retryable)
|
||||
SubtypeQuotaExceeded Subtype = "quota_exceeded" // resource quota / collection size limit reached (assignees, followers, members, etc.)
|
||||
SubtypeAlreadyExists Subtype = "already_exists" // idempotency violation: resource already exists in target state
|
||||
)
|
||||
|
||||
// CategoryPolicy subtypes (security-policy envelope shape)
|
||||
@@ -69,7 +79,12 @@ const (
|
||||
const (
|
||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
||||
SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read)
|
||||
SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save)
|
||||
// Generic untyped error lifted to InternalError uses SubtypeUnknown.
|
||||
)
|
||||
|
||||
// CategoryConfirmation subtypes intentionally have no declarations yet.
|
||||
// CategoryConfirmation subtypes
|
||||
const (
|
||||
SubtypeConfirmationRequired Subtype = "confirmation_required" // high-risk operation needs explicit --yes
|
||||
)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Service-specific Subtype declarations. Per-service files follow the
|
||||
// naming pattern subtypes_service_<name>.go so the framework's closed
|
||||
// Subtype enum stays readable while service taxonomies remain visible.
|
||||
|
||||
// Task service subtypes — consumed by internal/errclass/codemeta_task.go.
|
||||
const (
|
||||
SubtypeTaskInvalidParams Subtype = "task_invalid_params"
|
||||
SubtypeTaskPermissionDenied Subtype = "task_permission_denied"
|
||||
SubtypeTaskNotFound Subtype = "task_not_found"
|
||||
SubtypeTaskConflict Subtype = "task_conflict"
|
||||
SubtypeTaskServerError Subtype = "task_server_error"
|
||||
SubtypeTaskAssigneeLimit Subtype = "task_assignee_limit"
|
||||
SubtypeTaskFollowerLimit Subtype = "task_follower_limit"
|
||||
SubtypeTaskTasklistMemberLimit Subtype = "task_tasklist_member_limit"
|
||||
SubtypeTaskReminderExists Subtype = "task_reminder_exists"
|
||||
)
|
||||
14
errs/subtypes_shortcut.go
Normal file
14
errs/subtypes_shortcut.go
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
// Subtypes raised by the typed shortcut protocol (shortcuts/common). Only
|
||||
// cross-field semantic failures need their own subtype here; per-field
|
||||
// failures (required missing / enum invalid / typed-primitive format) reuse
|
||||
// SubtypeInvalidArgument.
|
||||
const (
|
||||
SubtypeShortcutOneOfMissing Subtype = "shortcut_oneof_missing"
|
||||
SubtypeShortcutOneOfMultiple Subtype = "shortcut_oneof_multiple"
|
||||
SubtypeShortcutGroupIncomplete Subtype = "shortcut_group_incomplete"
|
||||
)
|
||||
25
errs/subtypes_shortcut_test.go
Normal file
25
errs/subtypes_shortcut_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestShortcutSubtypes_Values(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
got Subtype
|
||||
want string
|
||||
}{
|
||||
{"OneOfMissing", SubtypeShortcutOneOfMissing, "shortcut_oneof_missing"},
|
||||
{"OneOfMultiple", SubtypeShortcutOneOfMultiple, "shortcut_oneof_multiple"},
|
||||
{"GroupIncomplete", SubtypeShortcutGroupIncomplete, "shortcut_group_incomplete"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if string(tt.got) != tt.want {
|
||||
t.Errorf("got %q, want %q", string(tt.got), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
678
errs/types.go
678
errs/types.go
@@ -3,13 +3,80 @@
|
||||
|
||||
package errs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// formatMessage applies fmt.Sprintf only when args are present, so a
|
||||
// caller passing a literal message with a stray "%" (e.g. "disk 100% full")
|
||||
// is not rendered as "%!(NOVERB)". `go vet -printf` catches most accidental
|
||||
// format misuse upstream; this guard makes the constructor safe even when
|
||||
// the message string is dynamically composed.
|
||||
func formatMessage(format string, args []any) string {
|
||||
if len(args) == 0 {
|
||||
return format
|
||||
}
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
|
||||
// Typed error types and their builder APIs.
|
||||
//
|
||||
// Each typed error has:
|
||||
// - A struct embedding Problem, with type-specific extension fields
|
||||
// - A nil-safe Unwrap() method when the struct carries a Cause field
|
||||
// - A NewXxxError(subtype, format, args...) constructor — Category locked
|
||||
// by the function name, Subtype + Message positional and required
|
||||
// - Chainable WithX(...) setters that return the concrete *XxxError pointer
|
||||
// so type-specific setters remain reachable to the end of the chain
|
||||
//
|
||||
// Preferred shape for new code:
|
||||
//
|
||||
// return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
// "invalid --start: %v", err).
|
||||
// WithHint("expected RFC3339, e.g. 2026-05-26T10:00:00Z").
|
||||
// WithParam("--start")
|
||||
//
|
||||
// Category is locked by the constructor name — it can never be mis-specified
|
||||
// at the call site. Subtype + Message are required positional arguments so the
|
||||
// compiler refuses to build a typed error missing either identity field.
|
||||
// Subtype well-formedness is enforced at PR time by the lint guard
|
||||
// CheckDeclaredSubtype (`lint/errscontract`), not at runtime, to avoid
|
||||
// coupling the typed package to a registry. ad_hoc_* subtypes are accepted
|
||||
// at runtime; CheckAdHocSubtype emits a follow-up warning.
|
||||
|
||||
// TypedError is implemented by all typed errors in this package.
|
||||
// It identifies a value as a typed envelope producer to the dispatcher,
|
||||
// which uses it to short-circuit promotion when the outer error is
|
||||
// already typed (avoiding overwrite of producer-set Subtype/Hint).
|
||||
type TypedError interface {
|
||||
error
|
||||
ProblemDetail() *Problem
|
||||
}
|
||||
|
||||
// ============================== ValidationError ==============================
|
||||
|
||||
// ValidationError is the typed error for CategoryValidation.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type ValidationError struct {
|
||||
Problem
|
||||
Param string `json:"param,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Param string `json:"param,omitempty"`
|
||||
Params []InvalidParam `json:"params,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InvalidParam is one structured validation diagnostic: the parameter that
|
||||
// failed (Name) and why (Reason). It mirrors an RFC 7807 "invalid-params"
|
||||
// item (RFC 7807 §3.1 extension members).
|
||||
//
|
||||
// The wire key on ValidationError is "params" rather than "invalid_params"
|
||||
// because the enclosing envelope already carries type:"validation", so the
|
||||
// "invalid" qualifier would be redundant on the wire. The Go type keeps the
|
||||
// InvalidParam prefix because, at package level, the name must self-describe.
|
||||
type InvalidParam struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Unwrap exposes the wrapped cause so errors.Unwrap / errors.Is can traverse
|
||||
@@ -22,6 +89,65 @@ func (e *ValidationError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Error returns the typed error message. Nil-safe — falls back to "" when the
|
||||
// receiver is a typed nil pointer, mirroring the embedded Problem.Error() guard
|
||||
// that promote-through-value-embed would otherwise bypass.
|
||||
func (e *ValidationError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
// NewValidationError constructs a *ValidationError with Category locked to
|
||||
// CategoryValidation and Message formatted via fmt.Sprintf(format, args...).
|
||||
func NewValidationError(subtype Subtype, format string, args ...any) *ValidationError {
|
||||
return &ValidationError{
|
||||
Problem: Problem{
|
||||
Category: CategoryValidation,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithHint(format string, args ...any) *ValidationError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithLogID(logID string) *ValidationError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithCode(code int) *ValidationError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithRetryable() *ValidationError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithParam(param string) *ValidationError {
|
||||
e.Param = param
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithParams(params ...InvalidParam) *ValidationError {
|
||||
e.Params = append(e.Params, params...)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ValidationError) WithCause(cause error) *ValidationError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// =========================== AuthenticationError =============================
|
||||
|
||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
@@ -39,17 +165,150 @@ func (e *AuthenticationError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// PermissionError is the typed error for CategoryAuthorization.
|
||||
type PermissionError struct {
|
||||
Problem
|
||||
MissingScopes []string `json:"missing_scopes,omitempty"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
ConsoleURL string `json:"console_url,omitempty"`
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *AuthenticationError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
// ConfigError is the typed error for CategoryConfig.
|
||||
func NewAuthenticationError(subtype Subtype, format string, args ...any) *AuthenticationError {
|
||||
return &AuthenticationError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthentication,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *AuthenticationError) WithHint(format string, args ...any) *AuthenticationError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *AuthenticationError) WithLogID(logID string) *AuthenticationError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *AuthenticationError) WithCode(code int) *AuthenticationError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *AuthenticationError) WithRetryable() *AuthenticationError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *AuthenticationError) WithUserOpenID(id string) *AuthenticationError {
|
||||
e.UserOpenID = id
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *AuthenticationError) WithCause(cause error) *AuthenticationError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// ============================= PermissionError ===============================
|
||||
|
||||
// PermissionError is the typed error for CategoryAuthorization.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type PermissionError struct {
|
||||
Problem
|
||||
MissingScopes []string `json:"missing_scopes,omitempty"`
|
||||
RequestedScopes []string `json:"requested_scopes,omitempty"`
|
||||
GrantedScopes []string `json:"granted_scopes,omitempty"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
ConsoleURL string `json:"console_url,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *PermissionError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *PermissionError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
func NewPermissionError(subtype Subtype, format string, args ...any) *PermissionError {
|
||||
return &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithHint(format string, args ...any) *PermissionError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithLogID(logID string) *PermissionError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithCode(code int) *PermissionError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithRetryable() *PermissionError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithMissingScopes(scopes ...string) *PermissionError {
|
||||
e.MissingScopes = slices.Clone(scopes)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithRequestedScopes(scopes ...string) *PermissionError {
|
||||
e.RequestedScopes = slices.Clone(scopes)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithGrantedScopes(scopes ...string) *PermissionError {
|
||||
e.GrantedScopes = slices.Clone(scopes)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithIdentity(identity string) *PermissionError {
|
||||
e.Identity = identity
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithConsoleURL(url string) *PermissionError {
|
||||
e.ConsoleURL = url
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *PermissionError) WithCause(cause error) *PermissionError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// =============================== ConfigError =================================
|
||||
|
||||
// ConfigError is the typed error for CategoryConfig. Cause preserves an
|
||||
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is
|
||||
// intentionally not serialized.
|
||||
type ConfigError struct {
|
||||
Problem
|
||||
Field string `json:"field,omitempty"`
|
||||
@@ -64,15 +323,63 @@ func (e *ConfigError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// NetworkError is the typed error for CategoryNetwork.
|
||||
// CauseKind (string) is one of: "timeout" | "tls" | "dns" | "5xx" — the
|
||||
// canonical wire taxonomy (emitted as JSON key "cause"). Cause preserves an
|
||||
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is intentionally
|
||||
// not serialized.
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *ConfigError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
func NewConfigError(subtype Subtype, format string, args ...any) *ConfigError {
|
||||
return &ConfigError{
|
||||
Problem: Problem{
|
||||
Category: CategoryConfig,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithHint(format string, args ...any) *ConfigError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithLogID(logID string) *ConfigError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithCode(code int) *ConfigError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithRetryable() *ConfigError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithField(field string) *ConfigError {
|
||||
e.Field = field
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfigError) WithCause(cause error) *ConfigError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// =============================== NetworkError ================================
|
||||
|
||||
// NetworkError is the typed error for CategoryNetwork. The Subtype carries
|
||||
// the failure taxonomy: timeout / tls / dns / server_error, with transport
|
||||
// as the fallback. Cause preserves an optional wrapped sentinel for
|
||||
// errors.Is / errors.Unwrap; it is intentionally not serialized.
|
||||
type NetworkError struct {
|
||||
Problem
|
||||
CauseKind string `json:"cause,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
@@ -83,13 +390,112 @@ func (e *NetworkError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// APIError is the typed error for CategoryAPI (catch-all for classified Lark API
|
||||
// business errors). Detail preserves the raw Lark error map for diagnostics.
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *NetworkError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
func NewNetworkError(subtype Subtype, format string, args ...any) *NetworkError {
|
||||
return &NetworkError{
|
||||
Problem: Problem{
|
||||
Category: CategoryNetwork,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *NetworkError) WithHint(format string, args ...any) *NetworkError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *NetworkError) WithLogID(logID string) *NetworkError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *NetworkError) WithCode(code int) *NetworkError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *NetworkError) WithRetryable() *NetworkError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *NetworkError) WithCause(cause error) *NetworkError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// ================================ APIError ===================================
|
||||
|
||||
// APIError is the typed error for CategoryAPI (catch-all for classified Lark
|
||||
// API business errors). Cause preserves an optional wrapped sentinel for
|
||||
// errors.Is / errors.Unwrap; it is intentionally not serialized.
|
||||
type APIError struct {
|
||||
Problem
|
||||
Detail map[string]any `json:"detail,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *APIError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *APIError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
func NewAPIError(subtype Subtype, format string, args ...any) *APIError {
|
||||
return &APIError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAPI,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *APIError) WithHint(format string, args ...any) *APIError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *APIError) WithLogID(logID string) *APIError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *APIError) WithCode(code int) *APIError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *APIError) WithRetryable() *APIError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *APIError) WithCause(cause error) *APIError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// =========================== SecurityPolicyError =============================
|
||||
|
||||
// SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes.
|
||||
// Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001.
|
||||
type SecurityPolicyError struct {
|
||||
@@ -106,14 +512,125 @@ func (e *SecurityPolicyError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *SecurityPolicyError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
func NewSecurityPolicyError(subtype Subtype, format string, args ...any) *SecurityPolicyError {
|
||||
return &SecurityPolicyError{
|
||||
Problem: Problem{
|
||||
Category: CategoryPolicy,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *SecurityPolicyError) WithHint(format string, args ...any) *SecurityPolicyError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *SecurityPolicyError) WithLogID(logID string) *SecurityPolicyError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *SecurityPolicyError) WithCode(code int) *SecurityPolicyError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *SecurityPolicyError) WithRetryable() *SecurityPolicyError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *SecurityPolicyError) WithChallengeURL(url string) *SecurityPolicyError {
|
||||
e.ChallengeURL = url
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *SecurityPolicyError) WithCause(cause error) *SecurityPolicyError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// ============================ ContentSafetyError =============================
|
||||
|
||||
// ContentSafetyError is the typed error for CategoryPolicy content-safety subtypes.
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type ContentSafetyError struct {
|
||||
Problem
|
||||
Rules []string `json:"rules,omitempty"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// InternalError is the typed error for CategoryInternal.
|
||||
// Cause is preserved for logging but not emitted on the wire.
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *ContentSafetyError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *ContentSafetyError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
func NewContentSafetyError(subtype Subtype, format string, args ...any) *ContentSafetyError {
|
||||
return &ContentSafetyError{
|
||||
Problem: Problem{
|
||||
Category: CategoryPolicy,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ContentSafetyError) WithHint(format string, args ...any) *ContentSafetyError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ContentSafetyError) WithLogID(logID string) *ContentSafetyError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ContentSafetyError) WithCode(code int) *ContentSafetyError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ContentSafetyError) WithRetryable() *ContentSafetyError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ContentSafetyError) WithRules(rules ...string) *ContentSafetyError {
|
||||
e.Rules = slices.Clone(rules)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ContentSafetyError) WithCause(cause error) *ContentSafetyError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// =============================== InternalError ===============================
|
||||
|
||||
// InternalError is the typed error for CategoryInternal. Cause is preserved
|
||||
// for logging but not emitted on the wire.
|
||||
type InternalError struct {
|
||||
Problem
|
||||
Cause error `json:"-"`
|
||||
@@ -127,10 +644,127 @@ func (e *InternalError) Unwrap() error {
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *InternalError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
func NewInternalError(subtype Subtype, format string, args ...any) *InternalError {
|
||||
return &InternalError{
|
||||
Problem: Problem{
|
||||
Category: CategoryInternal,
|
||||
Subtype: subtype,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *InternalError) WithHint(format string, args ...any) *InternalError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *InternalError) WithLogID(logID string) *InternalError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *InternalError) WithCode(code int) *InternalError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *InternalError) WithRetryable() *InternalError {
|
||||
e.Retryable = true
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *InternalError) WithCause(cause error) *InternalError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
// ========================= ConfirmationRequiredError =========================
|
||||
|
||||
// Risk classifies the impact of a confirmation-required operation. Every
|
||||
// ConfirmationRequiredError MUST populate Risk; callers without a known
|
||||
// risk level use RiskUnknown so the envelope is never wire-invalid.
|
||||
const (
|
||||
RiskRead = "read"
|
||||
RiskWrite = "write"
|
||||
RiskHighRiskWrite = "high-risk-write"
|
||||
RiskUnknown = "unknown"
|
||||
)
|
||||
|
||||
// ConfirmationRequiredError is the typed error for CategoryConfirmation.
|
||||
// Risk is one of: "read" | "write" | "high-risk-write".
|
||||
// Risk is one of: "read" | "write" | "high-risk-write" | "unknown".
|
||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||
// it is intentionally not serialized.
|
||||
type ConfirmationRequiredError struct {
|
||||
Problem
|
||||
Risk string `json:"risk"`
|
||||
Action string `json:"action"`
|
||||
Cause error `json:"-"`
|
||||
}
|
||||
|
||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||
func (e *ConfirmationRequiredError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Cause
|
||||
}
|
||||
|
||||
// Error is nil-receiver safe; see ValidationError.Error.
|
||||
func (e *ConfirmationRequiredError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Problem.Error()
|
||||
}
|
||||
|
||||
// NewConfirmationRequiredError constructs a *ConfirmationRequiredError.
|
||||
// Risk + Action are wire-required (non-omitempty). Empty inputs are
|
||||
// normalized at the constructor boundary so callers cannot build a
|
||||
// wire-invalid envelope: risk falls back to RiskUnknown, action to
|
||||
// "unknown". risk is one of: "read" | "write" | "high-risk-write".
|
||||
func NewConfirmationRequiredError(risk, action, format string, args ...any) *ConfirmationRequiredError {
|
||||
if risk == "" {
|
||||
risk = RiskUnknown
|
||||
}
|
||||
if action == "" {
|
||||
action = "unknown"
|
||||
}
|
||||
return &ConfirmationRequiredError{
|
||||
Problem: Problem{
|
||||
Category: CategoryConfirmation,
|
||||
Subtype: SubtypeConfirmationRequired,
|
||||
Message: formatMessage(format, args),
|
||||
},
|
||||
Risk: risk,
|
||||
Action: action,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ConfirmationRequiredError) WithHint(format string, args ...any) *ConfirmationRequiredError {
|
||||
e.Hint = formatMessage(format, args)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfirmationRequiredError) WithLogID(logID string) *ConfirmationRequiredError {
|
||||
e.LogID = logID
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfirmationRequiredError) WithCode(code int) *ConfirmationRequiredError {
|
||||
e.Code = code
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *ConfirmationRequiredError) WithCause(cause error) *ConfirmationRequiredError {
|
||||
e.Cause = cause
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errs
|
||||
package errs_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// ============================== JSON shape & embed ==============================
|
||||
|
||||
func TestPermissionErrorJSONShape(t *testing.T) {
|
||||
perm := &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization,
|
||||
Subtype: SubtypeMissingScope,
|
||||
perm := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Message: "x",
|
||||
},
|
||||
MissingScopes: []string{"docx:document"},
|
||||
@@ -53,35 +57,35 @@ func TestPermissionErrorJSONShape(t *testing.T) {
|
||||
// PermissionError embeds Problem. ProblemOf works around this by routing
|
||||
// via the unexported problemCarrier interface.
|
||||
func TestEmbedSemanticChasm(t *testing.T) {
|
||||
perm := &PermissionError{
|
||||
Problem: Problem{
|
||||
Category: CategoryAuthorization,
|
||||
Subtype: SubtypeMissingScope,
|
||||
perm := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Message: "missing",
|
||||
},
|
||||
}
|
||||
|
||||
var p *Problem
|
||||
var p *errs.Problem
|
||||
if errors.As(perm, &p) {
|
||||
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
|
||||
}
|
||||
|
||||
got, ok := ProblemOf(perm)
|
||||
got, ok := errs.ProblemOf(perm)
|
||||
if !ok {
|
||||
t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem")
|
||||
}
|
||||
if got != &perm.Problem {
|
||||
t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem)
|
||||
}
|
||||
if got.Category != CategoryAuthorization {
|
||||
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization)
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
|
||||
orig := errors.New("transport stalled")
|
||||
spe := &SecurityPolicyError{
|
||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"},
|
||||
spe := &errs.SecurityPolicyError{
|
||||
Problem: errs.Problem{Category: errs.CategoryPolicy, Subtype: errs.Subtype("challenge_required"), Message: "blocked"},
|
||||
Cause: orig,
|
||||
}
|
||||
if got := errors.Unwrap(spe); got != orig {
|
||||
@@ -106,12 +110,12 @@ func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{"ValidationError", func() error { var e *ValidationError; return e.Unwrap() }},
|
||||
{"AuthenticationError", func() error { var e *AuthenticationError; return e.Unwrap() }},
|
||||
{"ConfigError", func() error { var e *ConfigError; return e.Unwrap() }},
|
||||
{"NetworkError", func() error { var e *NetworkError; return e.Unwrap() }},
|
||||
{"SecurityPolicyError", func() error { var e *SecurityPolicyError; return e.Unwrap() }},
|
||||
{"InternalError", func() error { var e *InternalError; return e.Unwrap() }},
|
||||
{"ValidationError", func() error { var e *errs.ValidationError; return e.Unwrap() }},
|
||||
{"AuthenticationError", func() error { var e *errs.AuthenticationError; return e.Unwrap() }},
|
||||
{"ConfigError", func() error { var e *errs.ConfigError; return e.Unwrap() }},
|
||||
{"NetworkError", func() error { var e *errs.NetworkError; return e.Unwrap() }},
|
||||
{"SecurityPolicyError", func() error { var e *errs.SecurityPolicyError; return e.Unwrap() }},
|
||||
{"InternalError", func() error { var e *errs.InternalError; return e.Unwrap() }},
|
||||
}
|
||||
for _, c := range checks {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -127,6 +131,44 @@ func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedError_NilReceiverError pins the nil-receiver guard on every typed
|
||||
// error's Error(). Each typed error must define its own Error() method that
|
||||
// nil-guards the outer pointer; the embedded Problem.Error()'s nil guard is
|
||||
// bypassed because Go must dereference the outer pointer to reach the embedded
|
||||
// field via value-embed promotion.
|
||||
func TestTypedError_NilReceiverError(t *testing.T) {
|
||||
// Each typed error must define its own Error() method that nil-guards
|
||||
// the outer pointer; the embedded Problem.Error()'s nil guard is bypassed
|
||||
// because Go must dereference the outer pointer to reach the embedded field.
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"ValidationError", (*errs.ValidationError)(nil)},
|
||||
{"AuthenticationError", (*errs.AuthenticationError)(nil)},
|
||||
{"PermissionError", (*errs.PermissionError)(nil)},
|
||||
{"ConfigError", (*errs.ConfigError)(nil)},
|
||||
{"NetworkError", (*errs.NetworkError)(nil)},
|
||||
{"APIError", (*errs.APIError)(nil)},
|
||||
{"InternalError", (*errs.InternalError)(nil)},
|
||||
{"SecurityPolicyError", (*errs.SecurityPolicyError)(nil)},
|
||||
{"ContentSafetyError", (*errs.ContentSafetyError)(nil)},
|
||||
{"ConfirmationRequiredError", (*errs.ConfirmationRequiredError)(nil)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Fatalf("(*%s)(nil).Error() panicked: %v", tc.name, r)
|
||||
}
|
||||
}()
|
||||
if got := tc.err.Error(); got != "" {
|
||||
t.Errorf("(*%s)(nil).Error() = %q, want empty string", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedErrors_UnwrapPropagatesCause pins the positive Unwrap path so the
|
||||
// nil-safety guard above does not silently drop a real Cause on non-nil
|
||||
// receivers. Without this, a buggy refactor could change `return e.Cause` to
|
||||
@@ -137,12 +179,12 @@ func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
|
||||
name string
|
||||
err interface{ Unwrap() error }
|
||||
}{
|
||||
{"ValidationError", &ValidationError{Cause: cause}},
|
||||
{"AuthenticationError", &AuthenticationError{Cause: cause}},
|
||||
{"ConfigError", &ConfigError{Cause: cause}},
|
||||
{"NetworkError", &NetworkError{Cause: cause}},
|
||||
{"SecurityPolicyError", &SecurityPolicyError{Cause: cause}},
|
||||
{"InternalError", &InternalError{Cause: cause}},
|
||||
{"ValidationError", &errs.ValidationError{Cause: cause}},
|
||||
{"AuthenticationError", &errs.AuthenticationError{Cause: cause}},
|
||||
{"ConfigError", &errs.ConfigError{Cause: cause}},
|
||||
{"NetworkError", &errs.NetworkError{Cause: cause}},
|
||||
{"SecurityPolicyError", &errs.SecurityPolicyError{Cause: cause}},
|
||||
{"InternalError", &errs.InternalError{Cause: cause}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
@@ -152,3 +194,452 @@ func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// =============================== Builder API ===============================
|
||||
|
||||
// TestNewXxxError_LocksCategory verifies each constructor sets Category
|
||||
// from its function name; caller cannot mis-specify it.
|
||||
func TestNewXxxError_LocksCategory(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
got errs.Category
|
||||
want errs.Category
|
||||
}{
|
||||
{"validation", errs.NewValidationError(errs.SubtypeInvalidArgument, "x").Category, errs.CategoryValidation},
|
||||
{"authentication", errs.NewAuthenticationError(errs.SubtypeTokenMissing, "x").Category, errs.CategoryAuthentication},
|
||||
{"authorization", errs.NewPermissionError(errs.SubtypeMissingScope, "x").Category, errs.CategoryAuthorization},
|
||||
{"config", errs.NewConfigError(errs.SubtypeNotConfigured, "x").Category, errs.CategoryConfig},
|
||||
{"network", errs.NewNetworkError(errs.SubtypeNetworkTransport, "x").Category, errs.CategoryNetwork},
|
||||
{"api", errs.NewAPIError(errs.SubtypeRateLimit, "x").Category, errs.CategoryAPI},
|
||||
{"policy_security", errs.NewSecurityPolicyError(errs.SubtypeChallengeRequired, "x").Category, errs.CategoryPolicy},
|
||||
{"policy_content", errs.NewContentSafetyError(errs.SubtypeUnknown, "x").Category, errs.CategoryPolicy},
|
||||
{"internal", errs.NewInternalError(errs.SubtypeSDKError, "x").Category, errs.CategoryInternal},
|
||||
{"confirmation", errs.NewConfirmationRequiredError("write", "delete files", "x").Category, errs.CategoryConfirmation},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.got != tc.want {
|
||||
t.Errorf("Category = %q, want %q", tc.got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewXxxError_PrintfFormat verifies Message is formatted via fmt.Sprintf
|
||||
// just like fmt.Errorf — the canonical Go convention for error messages.
|
||||
func TestNewXxxError_PrintfFormat(t *testing.T) {
|
||||
cause := errors.New("boom")
|
||||
got := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"invalid --start (%s): %v", "yesterday", cause)
|
||||
want := "invalid --start (yesterday): boom"
|
||||
if got.Message != want {
|
||||
t.Errorf("Message = %q, want %q", got.Message, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewXxxError_LiteralPercentNoArgs pins the constructor's empty-args
|
||||
// fast path: a literal "%" in the message must NOT be rendered as
|
||||
// "%!(NOVERB)" when no args are passed.
|
||||
func TestNewXxxError_LiteralPercentNoArgs(t *testing.T) {
|
||||
got := errs.NewValidationError(errs.SubtypeInvalidArgument, "disk 100% full")
|
||||
if got.Message != "disk 100% full" {
|
||||
t.Errorf("Message = %q, want %q", got.Message, "disk 100% full")
|
||||
}
|
||||
hinted := errs.NewInternalError(errs.SubtypeStorage, "save failed").
|
||||
WithHint("only 5% headroom remains")
|
||||
if hinted.Hint != "only 5% headroom remains" {
|
||||
t.Errorf("Hint = %q, want %q", hinted.Hint, "only 5% headroom remains")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithChain_ReturnsConcretePointer verifies WithX setters return the
|
||||
// concrete *XxxError pointer, not *Problem — so chains preserve type and
|
||||
// type-specific setters remain reachable to the end of the chain.
|
||||
func TestWithChain_ReturnsConcretePointer(t *testing.T) {
|
||||
// Chain composition: only compiles if every intermediate result has
|
||||
// the concrete pointer type. Hint is on every type, Param is only on
|
||||
// ValidationError — chain must keep ValidationError type to reach it.
|
||||
got := errs.NewValidationError(errs.SubtypeInvalidArgument, "msg").
|
||||
WithHint("hint text").
|
||||
WithLogID("log-123").
|
||||
WithCode(42).
|
||||
WithRetryable().
|
||||
WithParam("--start").
|
||||
WithCause(errors.New("boom"))
|
||||
|
||||
if got.Hint != "hint text" {
|
||||
t.Errorf("Hint = %q, want %q", got.Hint, "hint text")
|
||||
}
|
||||
if got.LogID != "log-123" {
|
||||
t.Errorf("LogID = %q, want %q", got.LogID, "log-123")
|
||||
}
|
||||
if got.Code != 42 {
|
||||
t.Errorf("Code = %d, want %d", got.Code, 42)
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("Retryable = false, want true")
|
||||
}
|
||||
if got.Param != "--start" {
|
||||
t.Errorf("Param = %q, want %q", got.Param, "--start")
|
||||
}
|
||||
if got.Cause == nil || got.Cause.Error() != "boom" {
|
||||
t.Errorf("Cause = %v, want error 'boom'", got.Cause)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithChain_MutatesReceiver verifies WithX returns the same pointer
|
||||
// (not a copy) — chain edits propagate to the original construction.
|
||||
func TestWithChain_MutatesReceiver(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "msg")
|
||||
returned := e.WithHint("hint")
|
||||
if returned != e {
|
||||
t.Errorf("WithHint returned different pointer; want same as receiver")
|
||||
}
|
||||
if e.Hint != "hint" {
|
||||
t.Errorf("Receiver Hint not mutated: got %q", e.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithHint_PrintfFormat verifies WithHint follows fmt.Sprintf, matching
|
||||
// the constructor's printf convention.
|
||||
func TestWithHint_PrintfFormat(t *testing.T) {
|
||||
got := errs.NewValidationError(errs.SubtypeInvalidArgument, "x").
|
||||
WithHint("expected one of: %v", []string{"7d", "1m"})
|
||||
want := "expected one of: [7d 1m]"
|
||||
if got.Hint != want {
|
||||
t.Errorf("Hint = %q, want %q", got.Hint, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPermissionError_FullChain verifies the most field-heavy typed error
|
||||
// constructs cleanly via the chain.
|
||||
func TestPermissionError_FullChain(t *testing.T) {
|
||||
got := errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"--confirm-send requires scope: %s", "mail:user_mailbox.message:send").
|
||||
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
|
||||
WithMissingScopes("mail:user_mailbox.message:send").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
}
|
||||
if got.Subtype != errs.SubtypeMissingScope {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeMissingScope)
|
||||
}
|
||||
if len(got.MissingScopes) != 1 || got.MissingScopes[0] != "mail:user_mailbox.message:send" {
|
||||
t.Errorf("MissingScopes = %v, want [mail:user_mailbox.message:send]", got.MissingScopes)
|
||||
}
|
||||
if got.Identity != "user" {
|
||||
t.Errorf("Identity = %q, want %q", got.Identity, "user")
|
||||
}
|
||||
if got.ConsoleURL == "" {
|
||||
t.Error("ConsoleURL is empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithMissingScopes_VariadicAndSliceExpansion verifies both forms work.
|
||||
func TestWithMissingScopes_VariadicAndSliceExpansion(t *testing.T) {
|
||||
t.Run("variadic", func(t *testing.T) {
|
||||
got := errs.NewPermissionError(errs.SubtypeMissingScope, "x").
|
||||
WithMissingScopes("a:read", "b:write")
|
||||
if len(got.MissingScopes) != 2 {
|
||||
t.Errorf("got %v, want 2 elements", got.MissingScopes)
|
||||
}
|
||||
})
|
||||
t.Run("slice_expanded", func(t *testing.T) {
|
||||
scopes := []string{"a:read", "b:write"}
|
||||
got := errs.NewPermissionError(errs.SubtypeMissingScope, "x").
|
||||
WithMissingScopes(scopes...)
|
||||
if len(got.MissingScopes) != 2 {
|
||||
t.Errorf("got %v, want 2 elements", got.MissingScopes)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNetworkError_SubtypeAndChain verifies that a network failure carries
|
||||
// its canonical subtype, Retryable flag, and Unwrap chain together.
|
||||
func TestNetworkError_SubtypeAndChain(t *testing.T) {
|
||||
got := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "download failed: %v", errors.New("timeout")).
|
||||
WithCause(errors.New("context deadline exceeded")).
|
||||
WithRetryable()
|
||||
|
||||
if got.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypeNetworkTimeout)
|
||||
}
|
||||
if !got.Retryable {
|
||||
t.Errorf("Retryable = false, want true")
|
||||
}
|
||||
if got.Cause == nil {
|
||||
t.Error("Cause is nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewConfirmationRequiredError_RequiresRiskAndAction verifies the
|
||||
// constructor signature pins Risk + Action as positional args (non-omitempty
|
||||
// wire fields per types.go).
|
||||
func TestNewConfirmationRequiredError_RequiresRiskAndAction(t *testing.T) {
|
||||
got := errs.NewConfirmationRequiredError("high-risk-write", "delete 42 files",
|
||||
"this operation will delete %d files", 42)
|
||||
|
||||
if got.Risk != "high-risk-write" {
|
||||
t.Errorf("Risk = %q, want %q", got.Risk, "high-risk-write")
|
||||
}
|
||||
if got.Action != "delete 42 files" {
|
||||
t.Errorf("Action = %q, want %q", got.Action, "delete 42 files")
|
||||
}
|
||||
if got.Message != "this operation will delete 42 files" {
|
||||
t.Errorf("Message = %q", got.Message)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilder_ErrorsAsCompat verifies builder-constructed errors satisfy
|
||||
// errors.As / errors.Is for both the typed wrapper and any wrapped cause.
|
||||
func TestBuilder_ErrorsAsCompat(t *testing.T) {
|
||||
cause := errors.New("upstream failure")
|
||||
wrapped := errs.NewInternalError(errs.SubtypeSDKError, "wrap: %v", cause).WithCause(cause)
|
||||
|
||||
var asInternal *errs.InternalError
|
||||
if !errors.As(wrapped, &asInternal) {
|
||||
t.Error("errors.As should resolve to *InternalError")
|
||||
}
|
||||
if !errors.Is(wrapped, cause) {
|
||||
t.Error("errors.Is should resolve to original cause via Unwrap")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilder_WireFormat marshals a fully-built error and asserts the JSON
|
||||
// matches the canonical envelope shape. This complements marshal_test.go;
|
||||
// the focus here is verifying builder-set fields land in the right JSON
|
||||
// keys.
|
||||
func TestBuilder_WireFormat(t *testing.T) {
|
||||
e := errs.NewPermissionError(errs.SubtypeMissingScope, "missing scope %s", "calendar:event:create").
|
||||
WithCode(99991679).
|
||||
WithLogID("20260520-0a1b2c3d").
|
||||
WithHint("run lark-cli auth login --scope calendar:event:create").
|
||||
WithMissingScopes("calendar:event:create").
|
||||
WithIdentity("user").
|
||||
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth")
|
||||
|
||||
buf, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(buf, &got); err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
wantFields := map[string]any{
|
||||
"type": "authorization",
|
||||
"subtype": "missing_scope",
|
||||
"code": float64(99991679),
|
||||
"message": "missing scope calendar:event:create",
|
||||
"hint": "run lark-cli auth login --scope calendar:event:create",
|
||||
"log_id": "20260520-0a1b2c3d",
|
||||
"identity": "user",
|
||||
"console_url": "https://open.feishu.cn/app/cli_xxx/auth",
|
||||
"missing_scopes": []any{"calendar:event:create"},
|
||||
}
|
||||
for k, want := range wantFields {
|
||||
gotVal, ok := got[k]
|
||||
if !ok {
|
||||
t.Errorf("missing wire field %q in %v", k, got)
|
||||
continue
|
||||
}
|
||||
switch v := want.(type) {
|
||||
case []any:
|
||||
gotSlice, ok := gotVal.([]any)
|
||||
if !ok || len(gotSlice) != len(v) {
|
||||
t.Errorf("field %q = %v, want %v", k, gotVal, v)
|
||||
continue
|
||||
}
|
||||
for i := range v {
|
||||
if gotSlice[i] != v[i] {
|
||||
t.Errorf("field %q[%d] = %v, want %v", k, i, gotSlice[i], v[i])
|
||||
}
|
||||
}
|
||||
default:
|
||||
if gotVal != want {
|
||||
t.Errorf("field %q = %v, want %v", k, gotVal, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// retryable not set → must be absent (omitempty)
|
||||
if _, present := got["retryable"]; present {
|
||||
t.Errorf("retryable should be omitted when false, got %v", got["retryable"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuilder_WithRetryable_OmittedWhenFalse verifies omitempty behaviour:
|
||||
// retryable only appears on the wire when explicitly set to true.
|
||||
func TestBuilder_WithRetryable_OmittedWhenFalse(t *testing.T) {
|
||||
t.Run("absent_when_not_set", func(t *testing.T) {
|
||||
e := errs.NewNetworkError(errs.SubtypeNetworkTransport, "x")
|
||||
buf, _ := json.Marshal(e)
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(buf, &got)
|
||||
if _, ok := got["retryable"]; ok {
|
||||
t.Errorf("retryable present when unset; want omitted")
|
||||
}
|
||||
})
|
||||
t.Run("present_when_set", func(t *testing.T) {
|
||||
e := errs.NewNetworkError(errs.SubtypeNetworkTransport, "x").WithRetryable()
|
||||
buf, _ := json.Marshal(e)
|
||||
var got map[string]any
|
||||
_ = json.Unmarshal(buf, &got)
|
||||
v, ok := got["retryable"]
|
||||
if !ok || v != true {
|
||||
t.Errorf("retryable = %v ok=%v, want true present", v, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestNewSecurityPolicyError_ChallengeURL covers the Policy-specific field.
|
||||
func TestNewSecurityPolicyError_ChallengeURL(t *testing.T) {
|
||||
got := errs.NewSecurityPolicyError(errs.SubtypeChallengeRequired, "verify your device").
|
||||
WithCode(21000).
|
||||
WithChallengeURL("https://applink.feishu.cn/T/xxxxx")
|
||||
if got.ChallengeURL == "" {
|
||||
t.Error("ChallengeURL not set")
|
||||
}
|
||||
if got.Code != 21000 {
|
||||
t.Errorf("Code = %d, want 21000", got.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewContentSafetyError_Rules covers the variadic Rules setter.
|
||||
func TestNewContentSafetyError_Rules(t *testing.T) {
|
||||
got := errs.NewContentSafetyError(errs.SubtypeUnknown, "content blocked").
|
||||
WithRules("no_pii", "no_secrets")
|
||||
if len(got.Rules) != 2 {
|
||||
t.Errorf("Rules = %v, want 2 elements", got.Rules)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypedError_UnwrapSymmetry pins that every typed error carries a Cause
|
||||
// field that participates in errors.Unwrap / errors.Is. Uniformity across
|
||||
// all typed errors lets callers descend below the typed-error boundary
|
||||
// without first switching on the concrete type.
|
||||
func TestTypedError_UnwrapSymmetry(t *testing.T) {
|
||||
sentinel := errors.New("upstream cause")
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{"APIError", errs.NewAPIError(errs.SubtypeServerError, "x").WithCause(sentinel)},
|
||||
{"PermissionError", errs.NewPermissionError(errs.SubtypeMissingScope, "x").WithCause(sentinel)},
|
||||
{"ContentSafetyError", errs.NewContentSafetyError(errs.SubtypeUnknown, "x").WithCause(sentinel)},
|
||||
{"ConfirmationRequiredError", errs.NewConfirmationRequiredError("write", "cmd", "x").WithCause(sentinel)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name+"_Unwrap_returns_cause", func(t *testing.T) {
|
||||
if got := errors.Unwrap(tc.err); got != sentinel {
|
||||
t.Errorf("Unwrap() = %v, want %v", got, sentinel)
|
||||
}
|
||||
})
|
||||
t.Run(tc.name+"_errors.Is_sentinel", func(t *testing.T) {
|
||||
if !errors.Is(tc.err, sentinel) {
|
||||
t.Error("errors.Is(err, sentinel) = false, want true via Unwrap chain")
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("nil_receiver_Unwrap_safe", func(t *testing.T) {
|
||||
var p *errs.APIError
|
||||
_ = p.Unwrap()
|
||||
var pp *errs.PermissionError
|
||||
_ = pp.Unwrap()
|
||||
var c *errs.ContentSafetyError
|
||||
_ = c.Unwrap()
|
||||
var cr *errs.ConfirmationRequiredError
|
||||
_ = cr.Unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
// TestValidationError_WithParams covers the structured-validation extension:
|
||||
// WithParams appends InvalidParam items, the scalar Param setter is unaffected,
|
||||
// and the wire shape nests {name, reason} under "params" (omitted when empty).
|
||||
func TestValidationError_WithParams(t *testing.T) {
|
||||
t.Run("appends and exposes fields", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||
if len(e.Params) != 1 {
|
||||
t.Fatalf("len(Params) = %d, want 1", len(e.Params))
|
||||
}
|
||||
if e.Params[0].Name != "a.md" {
|
||||
t.Errorf("Params[0].Name = %q, want %q", e.Params[0].Name, "a.md")
|
||||
}
|
||||
if e.Params[0].Reason != "duplicate" {
|
||||
t.Errorf("Params[0].Reason = %q, want %q", e.Params[0].Reason, "duplicate")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends across multiple calls and returns receiver", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||
returned := e.WithParams(errs.InvalidParam{Name: "a.md", Reason: "dup"})
|
||||
if returned != e {
|
||||
t.Errorf("WithParams returned different pointer; want same as receiver")
|
||||
}
|
||||
e.WithParams(
|
||||
errs.InvalidParam{Name: "b.md", Reason: "dup"},
|
||||
errs.InvalidParam{Name: "c.md", Reason: "dup"},
|
||||
)
|
||||
if len(e.Params) != 3 {
|
||||
t.Fatalf("len(Params) = %d after two calls, want 3", len(e.Params))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("wire shape nests name and reason under params", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate rel_path").
|
||||
WithParam("--rel-path").
|
||||
WithParams(errs.InvalidParam{Name: "a.md", Reason: "duplicate"})
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
`"type":"validation"`,
|
||||
`"param":"--rel-path"`,
|
||||
`"params":[{"name":"a.md","reason":"duplicate"}]`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in %s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty Params omitted from wire", func(t *testing.T) {
|
||||
e := errs.NewValidationError(errs.SubtypeInvalidArgument, "x")
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal failed: %v", err)
|
||||
}
|
||||
if strings.Contains(string(b), `"params"`) {
|
||||
t.Errorf("empty Params should be omitted from wire; got %s", b)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuilderSetter_DefensiveCopy(t *testing.T) {
|
||||
t.Run("WithMissingScopes clones input", func(t *testing.T) {
|
||||
scopes := []string{"docx:document", "im:message:send"}
|
||||
err := errs.NewPermissionError(errs.SubtypeMissingScope, "test").
|
||||
WithMissingScopes(scopes...)
|
||||
scopes[0] = "MUTATED"
|
||||
if got := err.MissingScopes[0]; got != "docx:document" {
|
||||
t.Errorf("MissingScopes[0] = %q after caller mutation; want defensive copy", got)
|
||||
}
|
||||
})
|
||||
t.Run("WithRules clones input", func(t *testing.T) {
|
||||
rules := []string{"rule-A", "rule-B"}
|
||||
err := errs.NewContentSafetyError(errs.SubtypeUnknown, "test").
|
||||
WithRules(rules...)
|
||||
rules[0] = "MUTATED"
|
||||
if got := err.Rules[0]; got != "rule-A" {
|
||||
t.Errorf("Rules[0] = %q after caller mutation; want defensive copy", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ You should see `audit` in the plugin list.
|
||||
| `Observer` | Before / After each command | No (fire-and-forget audit) |
|
||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
||||
| `Restrict(Rule)` | Bootstrap-time, single per binary | Denies whole subtrees |
|
||||
| `Restrict(Rule)` | Bootstrap-time, ≥1 per plugin | Denies whole subtrees |
|
||||
|
||||
### Plugin lifecycle
|
||||
|
||||
@@ -102,10 +102,17 @@ the rejected dispatch.
|
||||
- A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder
|
||||
flips it automatically; the lower-level `Plugin` interface rejects
|
||||
the mismatch with `restricts_mismatch`.
|
||||
- Only ONE plugin per binary can call `Restrict()`. Multi-plugin
|
||||
Restrict is a deliberate `plugin_conflict` error (single-rule
|
||||
ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is
|
||||
shadowed by any plugin Restrict.
|
||||
- A plugin may call `Restrict()` more than once; each call adds one
|
||||
scoped Rule and the engine combines them with **OR** — a command is
|
||||
allowed when it satisfies every axis (allow / deny / max_risk /
|
||||
identities) of at least one rule. Note a rule's `deny` is scoped to
|
||||
that rule only and cannot veto another rule's allow. Only ONE plugin
|
||||
per binary may contribute rules, though: two DISTINCT plugins each
|
||||
calling `Restrict()` is a deliberate `multiple_restrict_plugins` error
|
||||
(single-owner assumption — an independent plugin must not be able to
|
||||
widen another's policy). YAML policy at `~/.lark-cli/policy.yml` (which
|
||||
may itself list several rules under `rules:`) is shadowed by any plugin
|
||||
Restrict.
|
||||
- The `Wrap` factory runs **once per command dispatch**, not at
|
||||
install time. Long-lived state (clients, caches, metrics counters)
|
||||
must live on the Plugin struct or in package-level variables.
|
||||
@@ -115,7 +122,8 @@ the rejected dispatch.
|
||||
- Commands missing a `risk_level` annotation are denied by default
|
||||
when a Rule is active. Set `Rule.AllowUnannotated = true` (or
|
||||
`allow_unannotated: true` in yaml) to opt out during gradual
|
||||
adoption.
|
||||
adoption. With several rules this is per-rule: an unannotated command
|
||||
is allowed as long as one rule that opts in also grants it.
|
||||
- Risk annotation typos (e.g. `"wrtie"`) are always denied with
|
||||
`risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated`
|
||||
does NOT bypass this — typo is a code bug, not a missing
|
||||
@@ -144,8 +152,7 @@ messages are localised and may change between releases.
|
||||
| `duplicate_hook_name` | Same hook name registered twice within a plugin | Yes |
|
||||
| `invalid_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | Yes |
|
||||
| `double_restrict` | Plugin called `r.Restrict()` more than once in one Install | Yes |
|
||||
| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes |
|
||||
| `multiple_restrict_plugins` | Two or more DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
|
||||
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
||||
| `install_panic` | `Plugin.Install` panicked | Yes |
|
||||
|
||||
@@ -165,6 +172,7 @@ might also be lying about being `FailOpen`).
|
||||
| `write_not_allowed` | Command risk is `write` / `high-risk-write` and exceeds Rule `max_risk` |
|
||||
| `risk_too_high` | Command risk exceeds Rule `max_risk` but is not a write (reserved for future risk levels) |
|
||||
| `identity_mismatch` | Command's `supportedIdentities` does not intersect Rule `identities` |
|
||||
| `no_matching_rule` | Several rules are active and the command satisfied none of them (the message summarises each rule's own rejection). Single-rule policies keep their specific reason_code instead |
|
||||
| `aggregate_all_denied` | Aggregate stub installed on a parent group because every live child was denied |
|
||||
|
||||
The `detail.layer` field distinguishes who rejected the call:
|
||||
|
||||
@@ -37,7 +37,7 @@ type Builder struct {
|
||||
caps Capabilities
|
||||
|
||||
actions []func(Registrar)
|
||||
rule *Rule
|
||||
rules []*Rule
|
||||
|
||||
hookNames map[string]bool
|
||||
errs []error
|
||||
@@ -125,7 +125,8 @@ func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler)
|
||||
// sets Restricts=true and FailurePolicy=FailClosed (the framework
|
||||
// requires both to coexist; the builder enforces the pairing so the
|
||||
// plugin author cannot accidentally ship a policy plugin under
|
||||
// FailOpen).
|
||||
// FailOpen). It may be called more than once; each call adds one scoped
|
||||
// Rule and the engine OR-combines them.
|
||||
func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
if rule == nil {
|
||||
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be nil"))
|
||||
@@ -133,7 +134,14 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
}
|
||||
b.caps.Restricts = true
|
||||
b.caps.FailurePolicy = FailClosed
|
||||
b.rule = rule
|
||||
// Defensive clone: capture an independent snapshot so a caller that
|
||||
// reuses and mutates the same *Rule across multiple Restrict calls
|
||||
// gets distinct entries (mirrors the staging registrar's clone).
|
||||
cp := *rule
|
||||
cp.Allow = append([]string(nil), rule.Allow...)
|
||||
cp.Deny = append([]string(nil), rule.Deny...)
|
||||
cp.Identities = append([]Identity(nil), rule.Identities...)
|
||||
b.rules = append(b.rules, &cp)
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -143,7 +151,7 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||
// The Restrict + FailOpen mismatch is checked here, not in the chained
|
||||
// setters, because the two methods may be called in either order.
|
||||
func (b *Builder) Build() (Plugin, error) {
|
||||
if b.rule != nil && b.caps.FailurePolicy == FailOpen {
|
||||
if len(b.rules) > 0 && b.caps.FailurePolicy == FailOpen {
|
||||
b.errs = append(b.errs, errors.New(
|
||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||
}
|
||||
@@ -155,7 +163,7 @@ func (b *Builder) Build() (Plugin, error) {
|
||||
version: b.version,
|
||||
caps: b.caps,
|
||||
actions: b.actions,
|
||||
rule: b.rule,
|
||||
rules: b.rules,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -198,15 +206,15 @@ type builtPlugin struct {
|
||||
version string
|
||||
caps Capabilities
|
||||
actions []func(Registrar)
|
||||
rule *Rule
|
||||
rules []*Rule
|
||||
}
|
||||
|
||||
func (p *builtPlugin) Name() string { return p.name }
|
||||
func (p *builtPlugin) Version() string { return p.version }
|
||||
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
|
||||
func (p *builtPlugin) Install(r Registrar) error {
|
||||
if p.rule != nil {
|
||||
r.Restrict(p.rule)
|
||||
for _, rule := range p.rules {
|
||||
r.Restrict(rule)
|
||||
}
|
||||
for _, action := range p.actions {
|
||||
action(r)
|
||||
|
||||
@@ -17,7 +17,8 @@ type recorder struct {
|
||||
observers int
|
||||
wrappers int
|
||||
lifecycles int
|
||||
rule *platform.Rule
|
||||
rule *platform.Rule // last rule (existing single-rule assertions)
|
||||
rules []*platform.Rule // every rule, in Restrict order
|
||||
}
|
||||
|
||||
func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Observer) {
|
||||
@@ -25,7 +26,39 @@ func (r *recorder) Observe(platform.When, string, platform.Selector, platform.Ob
|
||||
}
|
||||
func (r *recorder) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ }
|
||||
func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ }
|
||||
func (r *recorder) Restrict(rule *platform.Rule) { r.rule = rule }
|
||||
func (r *recorder) Restrict(rule *platform.Rule) {
|
||||
r.rule = rule
|
||||
r.rules = append(r.rules, rule)
|
||||
}
|
||||
|
||||
// Restrict must snapshot each rule: a caller that reuses and mutates the
|
||||
// same *Rule object across two Restrict calls must still get two distinct
|
||||
// rules at Install time, not two pointers to the last mutation.
|
||||
func TestBuilder_restrictClonesEachRule(t *testing.T) {
|
||||
shared := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead}
|
||||
b := platform.NewPlugin("p", "0").Restrict(shared)
|
||||
// Reuse and mutate the same object, then register it again.
|
||||
shared.Name = "im-rw"
|
||||
shared.Allow[0] = "im/**"
|
||||
shared.MaxRisk = platform.RiskWrite
|
||||
p, err := b.Restrict(shared).Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Build: %v", err)
|
||||
}
|
||||
r := &recorder{}
|
||||
if err := p.Install(r); err != nil {
|
||||
t.Fatalf("Install: %v", err)
|
||||
}
|
||||
if len(r.rules) != 2 {
|
||||
t.Fatalf("got %d rules, want 2", len(r.rules))
|
||||
}
|
||||
if r.rules[0].Name != "docs-ro" || r.rules[0].Allow[0] != "docs/**" || r.rules[0].MaxRisk != platform.RiskRead {
|
||||
t.Errorf("rule[0] leaked later mutation: %+v", r.rules[0])
|
||||
}
|
||||
if r.rules[1].Name != "im-rw" || r.rules[1].Allow[0] != "im/**" {
|
||||
t.Errorf("rule[1] = %+v, want im-rw / im/**", r.rules[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuilder_basicAssembly(t *testing.T) {
|
||||
p, err := platform.NewPlugin("audit", "0.1.0").
|
||||
|
||||
@@ -13,9 +13,10 @@ package platform
|
||||
// identifier is "{plugin}.{hook}". A plugin cannot register two hooks
|
||||
// with the same name in the same Install call.
|
||||
//
|
||||
// Restrict may be called at most once per plugin; multiple plugins
|
||||
// contributing Restrict() is a configuration error (the resolver
|
||||
// aborts startup).
|
||||
// Restrict may be called multiple times per plugin; each call adds one
|
||||
// scoped Rule (OR-combined by the engine). Two or more DISTINCT plugins
|
||||
// contributing Restrict() is a configuration error (the resolver aborts
|
||||
// startup).
|
||||
type Registrar interface {
|
||||
// Observe registers a side-effect-only command hook at the given
|
||||
// When stage. The selector decides which commands it fires on.
|
||||
@@ -29,8 +30,9 @@ type Registrar interface {
|
||||
// On registers a lifecycle handler for the given event.
|
||||
On(event LifecycleEvent, hookName string, fn LifecycleHandler)
|
||||
|
||||
// Restrict contributes a pruning Rule. The framework merges it
|
||||
// with the yaml-sourced Rule using single-rule semantics: plugin
|
||||
// rule wins, but two plugins both calling Restrict abort startup.
|
||||
// Restrict contributes a pruning Rule. May be called more than once
|
||||
// to declare several scoped grants (OR-combined by the engine).
|
||||
// Plugin rules take precedence over the yaml source; two distinct
|
||||
// plugins both calling Restrict abort startup.
|
||||
Restrict(r *Rule)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -214,7 +214,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
||||
}
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return nil, fmt.Errorf("token refresh parse error: %v", err)
|
||||
return nil, fmt.Errorf("token refresh parse error: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string)
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
||||
return fmt.Errorf("failed to parse response: %v", err)
|
||||
return fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
return fmt.Errorf("[%d] %s", resp.Code, resp.Msg)
|
||||
|
||||
@@ -5,91 +5,130 @@ package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// rawAPIJSONHint guides users when an SDK or response body parse fails. The
|
||||
// most common cause is a non-JSON payload (file download endpoint hit without
|
||||
// `--output`, or an upstream HTML error page).
|
||||
const rawAPIJSONHint = "The endpoint may have returned an empty or non-standard JSON body. If it returns a file, rerun with --output."
|
||||
|
||||
// WrapDoAPIError upgrades malformed JSON decode errors from the SDK into
|
||||
// actionable API errors for raw `lark-cli api` calls. All other failures
|
||||
// remain network errors.
|
||||
//
|
||||
// Already-classified errors pass through unchanged: any *output.ExitError
|
||||
// (legacy envelope from output.ErrAuth / output.ErrAPI / output.ErrWithHint)
|
||||
// and any typed *errs.* error (carries an embedded Problem) keeps its own
|
||||
// category and exit code. This is what makes the wrap idempotent on the
|
||||
// auth/credential chain — resolveAccessToken returns output.ErrAuth for
|
||||
// missing tokens, and that classification must survive the SDK boundary.
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape (api_error + rawAPIJSONHint
|
||||
// on JSON-decode, network otherwise) for the wrap-from-untyped branch.
|
||||
// Preserved so SDK Do() callers keep the original envelope until per-domain
|
||||
// migration to typed errors. New code should route through
|
||||
// APIClient.CheckResponse (typed *errs.APIError) or construct
|
||||
// *errs.NetworkError / *errs.InternalError directly.
|
||||
// WrapDoAPIError converts SDK-boundary failures into typed errs.* errors:
|
||||
// already-typed errors pass through (idempotent), JSON-decode failures
|
||||
// become InternalError{SubtypeInvalidResponse}, everything else becomes
|
||||
// NetworkError with a chain-derived subtype (timeout / tls / dns /
|
||||
// server_error / transport-fallback).
|
||||
func WrapDoAPIError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var existing *output.ExitError
|
||||
if errors.As(err, &existing) {
|
||||
return err
|
||||
}
|
||||
|
||||
// (1) Pass-through any typed errs.* error.
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return err
|
||||
}
|
||||
if isJSONDecodeError(err, false) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
|
||||
// (2) JSON-decode failure at the SDK boundary → InternalError.
|
||||
if isJSONDecodeError(err) {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse,
|
||||
"SDK returned an invalid JSON response: %v", err).
|
||||
WithHint("%s", rawAPIJSONHint).
|
||||
WithCause(err)
|
||||
}
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
|
||||
// (3) Otherwise classify as a network failure with a chain-derived subtype.
|
||||
return errs.NewNetworkError(classifyNetworkSubtype(err),
|
||||
"API call failed: %v", err).
|
||||
WithCause(err)
|
||||
}
|
||||
|
||||
// WrapJSONResponseParseError upgrades empty or malformed JSON response bodies
|
||||
// into API errors with hints instead of generic parse failures.
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape (api_error + ExitAPI +
|
||||
// rawAPIJSONHint). The 3-branch behaviour is preserved so existing callers
|
||||
// of internal/client/response.go keep emitting the same envelope until
|
||||
// per-domain migration to typed errors.
|
||||
// WrapJSONResponseParseError lifts a response-layer JSON parse failure into
|
||||
// *errs.InternalError{Subtype: SubtypeInvalidResponse}. Empty body, malformed
|
||||
// JSON, and mid-stream EOFs all collapse to this single shape.
|
||||
func WrapJSONResponseParseError(err error, body []byte) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var e *errs.InternalError
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
"API returned an empty JSON response body", rawAPIJSONHint)
|
||||
e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an empty JSON response body")
|
||||
} else {
|
||||
e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err)
|
||||
}
|
||||
if isJSONDecodeError(err, true) {
|
||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
||||
}
|
||||
return output.ErrNetwork("API call failed: %v", err)
|
||||
return e.WithHint("%s", rawAPIJSONHint).WithCause(err)
|
||||
}
|
||||
|
||||
func isJSONDecodeError(err error, allowEOF bool) bool {
|
||||
// classifyNetworkSubtype maps an error chain to one of the network subtypes,
|
||||
// falling back to SubtypeNetworkTransport. Timeout is checked first because
|
||||
// a net.OpError can satisfy net.Error and also wrap a DNS sub-error in
|
||||
// pathological proxy configurations — we prefer the timeout signal.
|
||||
func classifyNetworkSubtype(err error) errs.Subtype {
|
||||
// (a) Timeout — net.Error.Timeout(), plus the SDK's typed timeout
|
||||
// errors (which do not implement net.Error).
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||
return errs.SubtypeNetworkTimeout
|
||||
}
|
||||
var sdkServerTimeout *larkcore.ServerTimeoutError
|
||||
if errors.As(err, &sdkServerTimeout) {
|
||||
return errs.SubtypeNetworkTimeout
|
||||
}
|
||||
var sdkClientTimeout *larkcore.ClientTimeoutError
|
||||
if errors.As(err, &sdkClientTimeout) {
|
||||
return errs.SubtypeNetworkTimeout
|
||||
}
|
||||
|
||||
// (b) TLS — typed x509 error or message substring fallback.
|
||||
var x509Err *x509.UnknownAuthorityError
|
||||
if errors.As(err, &x509Err) {
|
||||
return errs.SubtypeNetworkTLS
|
||||
}
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "x509:") || strings.Contains(msg, "tls:") {
|
||||
return errs.SubtypeNetworkTLS
|
||||
}
|
||||
|
||||
// (c) DNS — *net.DNSError covers SDK chains coming from net.Dialer.
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(err, &dnsErr) {
|
||||
return errs.SubtypeNetworkDNS
|
||||
}
|
||||
|
||||
// HTTP 5xx classification lives on the call sites with *http.Response
|
||||
// access (DoStream, HandleResponse); the SDK never surfaces non-504 5xx
|
||||
// as an error here.
|
||||
return errs.SubtypeNetworkTransport
|
||||
}
|
||||
|
||||
// isJSONDecodeError reports whether err is a JSON decode failure at the
|
||||
// SDK boundary, matching both typed json errors and their fmt.Errorf-
|
||||
// wrapped substring form. io.EOF is intentionally excluded — at the SDK
|
||||
// boundary an EOF is a transport failure, not a payload-shape failure.
|
||||
func isJSONDecodeError(err error) bool {
|
||||
var syntaxErr *json.SyntaxError
|
||||
var unmarshalTypeErr *json.UnmarshalTypeError
|
||||
|
||||
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
|
||||
return true
|
||||
}
|
||||
if allowEOF && (errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Substring fallback for fmt.Errorf-wrapped json decode errors that no
|
||||
// longer satisfy errors.As against the typed json errors. "invalid
|
||||
// character" alone is too broad (other libraries surface it for non-
|
||||
// JSON failures), so it is gated on the message also containing "json".
|
||||
msg := err.Error()
|
||||
if allowEOF && strings.Contains(msg, "unexpected EOF") {
|
||||
if strings.Contains(msg, "unexpected end of JSON input") ||
|
||||
strings.Contains(msg, "cannot unmarshal") {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(msg, "unexpected end of JSON input") ||
|
||||
strings.Contains(msg, "invalid character") ||
|
||||
strings.Contains(msg, "cannot unmarshal")
|
||||
lower := strings.ToLower(msg)
|
||||
return strings.Contains(lower, "invalid character") && strings.Contains(lower, "json")
|
||||
}
|
||||
|
||||
@@ -4,173 +4,312 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WrapDoAPIError: typed error contract.
|
||||
//
|
||||
// Pass-through: any error carrying *errs.Problem (detected via ProblemOf).
|
||||
// JSON decode failures → *errs.InternalError{Subtype: invalid_response}.
|
||||
// Otherwise → *errs.NetworkError with one of: timeout / tls / dns /
|
||||
// server_error / transport (fallback).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
// timeoutNetError implements net.Error with Timeout() == true. Used to exercise
|
||||
// the timeout branch of the network classifier without depending on a live
|
||||
// transport.
|
||||
type timeoutNetError struct{}
|
||||
|
||||
func (timeoutNetError) Error() string { return "i/o timeout" }
|
||||
func (timeoutNetError) Timeout() bool { return true }
|
||||
func (timeoutNetError) Temporary() bool { return true }
|
||||
|
||||
// TestWrapDoAPIError_SyntaxError_ReturnsInternalError pins that a raw
|
||||
// *json.SyntaxError from the SDK boundary surfaces as an *errs.InternalError
|
||||
// with Subtype=invalid_response — replacing the legacy api_error envelope.
|
||||
func TestWrapDoAPIError_SyntaxError_ReturnsInternalError(t *testing.T) {
|
||||
got := WrapDoAPIError(&json.SyntaxError{Offset: 1})
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(got, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T (%v)", got, got)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
if ie.Category != errs.CategoryInternal {
|
||||
t.Errorf("Category = %v, want %v", ie.Category, errs.CategoryInternal)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
|
||||
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
|
||||
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
// TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError pins the second
|
||||
// json-decode error variant (type-mismatch decoding) routes through the same
|
||||
// invalid_response branch — not the network fallback.
|
||||
func TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError(t *testing.T) {
|
||||
got := WrapDoAPIError(&json.UnmarshalTypeError{Value: "string", Type: nil})
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(got, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", got)
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
|
||||
t.Fatalf("expected invalid JSON diagnostic, got %#v", exitErr.Detail)
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic pins branch 1 of
|
||||
// the documented 3-branch behaviour: empty (or whitespace-only) response
|
||||
// bodies surface as api_error + rawAPIJSONHint, not network. Pages returning
|
||||
// only "\n" must not be reclassified as transport failures.
|
||||
func TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic(t *testing.T) {
|
||||
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
|
||||
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("body=%q: expected ExitError, got %T", body, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("body=%q: Code = %d, want %d", body, exitErr.Code, output.ExitAPI)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Errorf("body=%q: Detail.Type = %v, want api_error", body, exitErr.Detail)
|
||||
}
|
||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "empty JSON response") {
|
||||
t.Errorf("body=%q: Detail.Message = %v, want empty-body diagnostic", body, exitErr.Detail)
|
||||
}
|
||||
// TestWrapDoAPIError_Timeout pins that an SDK transport error whose chain
|
||||
// carries a net.Error with Timeout()==true classifies as
|
||||
// NetworkError{Subtype: timeout}. Covers the E2E timeout scenario
|
||||
// (HTTPS_PROXY pointing at a non-routable address).
|
||||
func TestWrapDoAPIError_Timeout(t *testing.T) {
|
||||
got := WrapDoAPIError(&net.OpError{Op: "dial", Net: "tcp", Err: timeoutNetError{}})
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T (%v)", got, got)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
|
||||
}
|
||||
if ne.Category != errs.CategoryNetwork {
|
||||
t.Errorf("Category = %v, want %v", ne.Category, errs.CategoryNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_NonJSONErrorIsNetwork pins branch 3:
|
||||
// a non-JSON-decode error with a non-empty body falls back to ErrNetwork
|
||||
// (the SDK delivered something but the read itself failed mid-flight).
|
||||
func TestWrapJSONResponseParseError_NonJSONErrorIsNetwork(t *testing.T) {
|
||||
raw := errors.New("connection reset by peer")
|
||||
err := WrapJSONResponseParseError(raw, []byte(`{"code":0,"data":{}}`))
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected ExitError, got %T", err)
|
||||
// TestWrapDoAPIError_TLS pins that an x509.UnknownAuthorityError classifies
|
||||
// as NetworkError{Subtype: tls}.
|
||||
func TestWrapDoAPIError_TLS(t *testing.T) {
|
||||
got := WrapDoAPIError(&x509.UnknownAuthorityError{})
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Errorf("Detail.Type = %v, want network", exitErr.Detail)
|
||||
if ne.Subtype != errs.SubtypeNetworkTLS {
|
||||
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_LegacyExitErrorPassesThrough pins the invariant that an
|
||||
// already-classified *output.ExitError (e.g. output.ErrAuth from
|
||||
// resolveAccessToken) survives WrapDoAPIError with its category and exit code
|
||||
// intact. Without this, missing-token errors regress from exit 3/auth to
|
||||
// exit 4/network at the SDK boundary.
|
||||
func TestWrapDoAPIError_LegacyExitErrorPassesThrough(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in error
|
||||
want int
|
||||
wantType string
|
||||
}{
|
||||
{"auth", output.ErrAuth("no access token available for user"), output.ExitAuth, "auth"},
|
||||
{"validation", output.ErrValidation("missing flag --foo"), output.ExitValidation, "validation"},
|
||||
{"api_unknown_code", output.ErrAPI(12345, "unknown lark code", nil), output.ExitAPI, "api_error"},
|
||||
// TestWrapDoAPIError_TLS_HandshakeMessage covers the message-substring fallback
|
||||
// for TLS errors that don't surface as a typed x509 error.
|
||||
func TestWrapDoAPIError_TLS_HandshakeMessage(t *testing.T) {
|
||||
got := WrapDoAPIError(errors.New("remote error: tls: handshake failure"))
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T", got)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := WrapDoAPIError(tc.in)
|
||||
if got != tc.in {
|
||||
t.Fatalf("expected identity passthrough, got %v (orig %v)", got, tc.in)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != tc.want {
|
||||
t.Fatalf("Code = %d, want %d", exitErr.Code, tc.want)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != tc.wantType {
|
||||
t.Fatalf("Detail.Type = %q, want %q (detail=%#v)",
|
||||
func() string {
|
||||
if exitErr.Detail == nil {
|
||||
return "<nil>"
|
||||
}
|
||||
return exitErr.Detail.Type
|
||||
}(),
|
||||
tc.wantType, exitErr.Detail)
|
||||
}
|
||||
})
|
||||
if ne.Subtype != errs.SubtypeNetworkTLS {
|
||||
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_TypedErrsPassesThrough pins that any *errs.* typed error
|
||||
// (carries an embedded Problem) passes through unchanged. Forward-compat for
|
||||
// stage-4 credential chain migration that will return *errs.AuthenticationError
|
||||
// directly instead of legacy output.ErrAuth.
|
||||
func TestWrapDoAPIError_TypedErrsPassesThrough(t *testing.T) {
|
||||
// TestWrapDoAPIError_DNS pins that a *net.DNSError classifies as
|
||||
// NetworkError{Subtype: dns}.
|
||||
func TestWrapDoAPIError_DNS(t *testing.T) {
|
||||
got := WrapDoAPIError(&net.DNSError{Name: "example.invalid"})
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T", got)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkDNS {
|
||||
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkDNS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_SDKServerTimeout pins that a *larkcore.ServerTimeoutError
|
||||
// (504 Gateway Timeout surfaced by the SDK as a typed error rather than an
|
||||
// *http.Response) classifies as timeout — upstream took too long to respond.
|
||||
func TestWrapDoAPIError_SDKServerTimeout(t *testing.T) {
|
||||
got := WrapDoAPIError(&larkcore.ServerTimeoutError{})
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T", got)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_SDKClientTimeout pins that a *larkcore.ClientTimeoutError
|
||||
// (client-side request timeout the SDK reports without satisfying net.Error)
|
||||
// classifies as timeout.
|
||||
func TestWrapDoAPIError_SDKClientTimeout(t *testing.T) {
|
||||
got := WrapDoAPIError(&larkcore.ClientTimeoutError{})
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T", got)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTimeout {
|
||||
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_UnknownCause_FallsBackToTransport pins the fallback:
|
||||
// when none of the specific causes match, NetworkError uses the generic
|
||||
// transport subtype.
|
||||
func TestWrapDoAPIError_UnknownCause_FallsBackToTransport(t *testing.T) {
|
||||
got := WrapDoAPIError(errors.New("connection reset by peer"))
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T", got)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("Subtype = %v, want %v (fallback)", ne.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_PassThrough_TypedError pins that any typed *errs.* error
|
||||
// (carrying an embedded Problem) passes through unchanged — same pointer
|
||||
// identity, no re-classification. This is the load-bearing invariant for
|
||||
// resolveAccessToken returning *errs.AuthenticationError through DoSDKRequest.
|
||||
func TestWrapDoAPIError_PassThrough_TypedError(t *testing.T) {
|
||||
cases := []error{
|
||||
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}},
|
||||
&errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}},
|
||||
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport}},
|
||||
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError}},
|
||||
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing, Message: "no token"}},
|
||||
&errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope, Message: "no scope"}},
|
||||
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport, Message: "transport"}},
|
||||
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, Message: "sdk"}},
|
||||
}
|
||||
for _, in := range cases {
|
||||
t.Run(fmt.Sprintf("%T", in), func(t *testing.T) {
|
||||
got := WrapDoAPIError(in)
|
||||
if got != in {
|
||||
t.Fatalf("expected identity passthrough, got %T %v", got, got)
|
||||
t.Fatalf("expected identity pass-through, got %T %v", got, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_PassthroughBeforeJSONDecode pins that even if a typed/legacy
|
||||
// error wraps a JSON decode error somewhere in its chain, the outer
|
||||
// classification takes precedence — we never re-classify an already-typed error
|
||||
// as a JSON parse error.
|
||||
func TestWrapDoAPIError_PassthroughBeforeJSONDecode(t *testing.T) {
|
||||
jsonErr := &json.SyntaxError{Offset: 1}
|
||||
authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), jsonErr)
|
||||
|
||||
got := WrapDoAPIError(authWrappingJSON)
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(got, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", got)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("outer auth classification should win, Code = %d want %d", exitErr.Code, output.ExitAuth)
|
||||
// TestWrapDoAPIError_Nil pins that nil in stays nil out (no allocation, no
|
||||
// panic). Callers rely on this when the SDK returns success.
|
||||
func TestWrapDoAPIError_Nil(t *testing.T) {
|
||||
if got := WrapDoAPIError(nil); got != nil {
|
||||
t.Errorf("WrapDoAPIError(nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// WrapJSONResponseParseError: typed error contract.
|
||||
//
|
||||
// All response-layer parse failures (empty body, malformed JSON, mid-stream
|
||||
// read failures that surface as parse errors) collapse to a single
|
||||
// *errs.InternalError{Subtype: invalid_response}. The rawAPIJSONHint is
|
||||
// preserved on Problem.Hint so users still get the "may have returned an
|
||||
// empty or non-standard body, rerun with --output" guidance.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError pins the
|
||||
// new shape for malformed JSON bodies — replaces the legacy api_error path.
|
||||
func TestWrapJSONResponseParseError_SyntaxError_ReturnsInternalError(t *testing.T) {
|
||||
got := WrapJSONResponseParseError(&json.SyntaxError{Offset: 1}, []byte("{ malformed"))
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(got, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", got)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if ie.Hint != rawAPIJSONHint {
|
||||
t.Errorf("Hint = %q, want rawAPIJSONHint preserved", ie.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError pins that
|
||||
// empty / whitespace-only response bodies also surface as invalid_response,
|
||||
// not as a network error. Endpoints returning only "\n" or "" trigger this.
|
||||
func TestWrapJSONResponseParseError_EmptyBody_ReturnsInternalError(t *testing.T) {
|
||||
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
|
||||
got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(got, &ie) {
|
||||
t.Fatalf("body=%q: expected *errs.InternalError, got %T", body, got)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("body=%q: Subtype = %v, want invalid_response", body, ie.Subtype)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError pins that
|
||||
// io.ErrUnexpectedEOF mid-decode also surfaces as invalid_response — keeps
|
||||
// the legacy non-empty-body decode-failure semantics under the new typed
|
||||
// envelope.
|
||||
func TestWrapJSONResponseParseError_UnexpectedEOF_ReturnsInternalError(t *testing.T) {
|
||||
got := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(got, &ie) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T", got)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %v, want invalid_response", ie.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapJSONResponseParseError_Nil pins nil pass-through.
|
||||
func TestWrapJSONResponseParseError_Nil(t *testing.T) {
|
||||
if got := WrapJSONResponseParseError(nil, []byte("anything")); got != nil {
|
||||
t.Errorf("WrapJSONResponseParseError(nil, ...) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Cross-cutting: existing tests already in this file (kept and adjusted below).
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough pins that legacy
|
||||
// *output.ExitError (auth/validation/api flavours) is NOT a problemCarrier
|
||||
// and is therefore not pass-through — only typed *errs.* values are.
|
||||
// Legacy values fall through to the network/JSON branches based on their
|
||||
// inner shape.
|
||||
func TestWrapDoAPIError_LegacyExitErrorNoLongerPassesThrough(t *testing.T) {
|
||||
// An *output.ErrAuth has no embedded Problem and no JSON-decode chain;
|
||||
// it routes to the network branch with the fallback transport subtype.
|
||||
got := WrapDoAPIError(output.ErrAuth("no access token available for user"))
|
||||
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(got, &ne) {
|
||||
t.Fatalf("expected *errs.NetworkError (legacy ExitError no longer pass-through), got %T (%v)", got, got)
|
||||
}
|
||||
// Sanity: not silently re-classified as JSON-decode.
|
||||
var ie *errs.InternalError
|
||||
if errors.As(got, &ie) {
|
||||
t.Fatalf("expected NetworkError, got InternalError %v", ie)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins pins that a typed
|
||||
// *errs.AuthenticationError wrapping a JSON syntax error in its chain still
|
||||
// passes through as the outer type — we never re-classify a typed problem
|
||||
// carrier just because the chain contains a json.SyntaxError. Forward-compat
|
||||
// for credential chain errors that bundle a parse failure as Cause.
|
||||
func TestWrapDoAPIError_TypedErrorWrappingJSON_OuterWins(t *testing.T) {
|
||||
jsonErr := &json.SyntaxError{Offset: 1}
|
||||
outer := &errs.AuthenticationError{
|
||||
Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired, Message: "expired"},
|
||||
Cause: jsonErr,
|
||||
}
|
||||
|
||||
got := WrapDoAPIError(outer)
|
||||
if got != outer {
|
||||
t.Fatalf("expected outer typed error to win, got %T %v", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrapDoAPIError_MessageContainsCause pins that the wrapped error's
|
||||
// message is carried into Problem.Message so logs / debugging retain the
|
||||
// underlying cause string.
|
||||
func TestWrapDoAPIError_MessageContainsCause(t *testing.T) {
|
||||
raw := errors.New("dial tcp 10.0.0.1:443: i/o timeout")
|
||||
got := WrapDoAPIError(raw)
|
||||
if !strings.Contains(got.Error(), "i/o timeout") {
|
||||
t.Errorf("Error() = %q, want to contain underlying cause", got.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,12 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/errcompat"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
)
|
||||
@@ -48,16 +52,38 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
|
||||
if err != nil {
|
||||
var unavailableErr *credential.TokenUnavailableError
|
||||
if errors.As(err, &unavailableErr) {
|
||||
return "", output.ErrAuth("no access token available for %s", as)
|
||||
return "", newTokenMissingError(as, unavailableErr)
|
||||
}
|
||||
// NeedAuthorizationError from the credential chain (e.g. UAT refresh
|
||||
// returned need_user_authorization) must surface as typed
|
||||
// AuthenticationError. Without this, WrapDoAPIError would wrap the
|
||||
// raw err as NetworkError, and cmd/root.go's outer-typed gate would
|
||||
// then skip PromoteAuthError — leaving the user with exit 4 and no
|
||||
// auth-login hint instead of exit 3 typed authentication.
|
||||
var needAuthErr *internalauth.NeedAuthorizationError
|
||||
if errors.As(err, &needAuthErr) {
|
||||
return "", errcompat.PromoteAuthError(needAuthErr)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if result.Token == "" {
|
||||
return "", output.ErrAuth("no access token available for %s", as)
|
||||
return "", newTokenMissingError(as, nil)
|
||||
}
|
||||
return result.Token, nil
|
||||
}
|
||||
|
||||
// newTokenMissingError builds the typed *errs.AuthenticationError that
|
||||
// resolveAccessToken returns when no usable token is available for the
|
||||
// requested identity. cause is the underlying credential-chain error (or nil
|
||||
// for the defensive empty-token branch) and is preserved for errors.Is /
|
||||
// errors.Unwrap traversal without being serialized on the wire.
|
||||
func newTokenMissingError(as core.Identity, cause error) error {
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"no access token available for %s", as).
|
||||
WithHint("run: lark-cli auth login to re-authorize").
|
||||
WithCause(cause)
|
||||
}
|
||||
|
||||
// buildApiReq converts a RawApiRequest into SDK types and collects
|
||||
// request-specific options (ExtraOpts, URL-based headers).
|
||||
// Auth is handled separately by DoSDKRequest.
|
||||
@@ -93,14 +119,14 @@ func (c *APIClient) buildApiReq(request RawApiRequest) (*larkcore.ApiReq, []lark
|
||||
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
|
||||
//
|
||||
// SDK Do() failures are normalised through WrapDoAPIError so every caller
|
||||
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without each
|
||||
// one remembering to wrap. In stage 1 that wire shape is still the legacy
|
||||
// *output.ExitError envelope (network / api_error) — the stage-4 framework
|
||||
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
|
||||
// each one remembering to wrap. Today that wire shape is still the legacy
|
||||
// *output.ExitError envelope (network / api_error); future framework-
|
||||
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
|
||||
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
|
||||
// Errors that arrive already-classified (legacy *output.ExitError from
|
||||
// resolveAccessToken's missing-credential paths, or a typed *errs.* from
|
||||
// future stages) flow through unchanged.
|
||||
// resolveAccessToken's missing-credential paths, or a typed *errs.*) flow
|
||||
// through unchanged.
|
||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
|
||||
@@ -177,7 +203,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, output.ErrNetwork("stream request failed: %s", err)
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Apply headers from opts
|
||||
@@ -195,7 +221,7 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
resp, err := httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, output.ErrNetwork("stream request failed: %s", err)
|
||||
return nil, errs.NewNetworkError(classifyNetworkSubtype(err), "stream request failed: %s", err).WithCause(err)
|
||||
}
|
||||
resp.Body = &cancelOnCloseBody{ReadCloser: resp.Body, cancel: cancel}
|
||||
|
||||
@@ -204,31 +230,32 @@ func (c *APIClient) DoStream(ctx context.Context, req *larkcore.ApiReq, as core.
|
||||
defer resp.Body.Close()
|
||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
msg := strings.TrimSpace(string(errBody))
|
||||
if msg != "" {
|
||||
err := output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
|
||||
attachStreamLogID(err, resp.Header)
|
||||
return nil, err
|
||||
subtype := errs.SubtypeNetworkTransport
|
||||
if resp.StatusCode >= 500 {
|
||||
subtype = errs.SubtypeNetworkServer
|
||||
}
|
||||
err := output.ErrNetwork("HTTP %d", resp.StatusCode)
|
||||
attachStreamLogID(err, resp.Header)
|
||||
return nil, err
|
||||
var netErr *errs.NetworkError
|
||||
if msg != "" {
|
||||
netErr = errs.NewNetworkError(subtype, "HTTP %d: %s", resp.StatusCode, msg)
|
||||
} else {
|
||||
netErr = errs.NewNetworkError(subtype, "HTTP %d", resp.StatusCode)
|
||||
}
|
||||
netErr = netErr.WithCode(resp.StatusCode)
|
||||
if logID := streamLogID(resp.Header); logID != "" {
|
||||
netErr = netErr.WithLogID(logID)
|
||||
}
|
||||
return nil, netErr
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func attachStreamLogID(err *output.ExitError, header http.Header) {
|
||||
if err == nil || err.Detail == nil {
|
||||
return
|
||||
}
|
||||
func streamLogID(header http.Header) string {
|
||||
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
|
||||
if logID == "" {
|
||||
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
|
||||
}
|
||||
if logID == "" {
|
||||
return
|
||||
}
|
||||
err.Detail.Detail = map[string]any{"log_id": logID}
|
||||
return logID
|
||||
}
|
||||
|
||||
type cancelOnCloseBody struct {
|
||||
@@ -256,10 +283,10 @@ func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error)
|
||||
pathKey := strings.TrimPrefix(segment, ":")
|
||||
pathValue, ok := req.PathParams[pathKey]
|
||||
if !ok {
|
||||
return "", output.ErrValidation("missing path param %q for %s", pathKey, req.ApiPath)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "missing path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
|
||||
}
|
||||
if pathValue == "" {
|
||||
return "", output.ErrValidation("empty path param %q for %s", pathKey, req.ApiPath)
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "empty path param %q for %s", pathKey, req.ApiPath).WithParam(pathKey)
|
||||
}
|
||||
pathSegs = append(pathSegs, url.PathEscape(pathValue))
|
||||
}
|
||||
@@ -285,7 +312,7 @@ func buildStreamBody(body interface{}) (io.Reader, string, error) {
|
||||
default:
|
||||
payload, err := json.Marshal(typed)
|
||||
if err != nil {
|
||||
return nil, "", output.Errorf(output.ExitInternal, "api_error", "failed to encode request body: %s", err)
|
||||
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "failed to encode request body: %s", err).WithCause(err)
|
||||
}
|
||||
return bytes.NewReader(payload), "application/json", nil
|
||||
}
|
||||
@@ -306,11 +333,9 @@ func (c *APIClient) DoAPI(ctx context.Context, request RawApiRequest) (*larkcore
|
||||
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
|
||||
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
|
||||
// see an *output.ExitError envelope (api_error for malformed JSON, network
|
||||
// for everything else) instead of a bare fmt.Errorf. Without this, an empty
|
||||
// for everything else) instead of a bare fmt.Errorf — otherwise an empty
|
||||
// or malformed page body would surface to the root handler as a plain-text
|
||||
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
|
||||
// framework-boundary migration will flip this wrapper to typed
|
||||
// *errs.InternalError / *errs.NetworkError.
|
||||
// "Error: ..." line and bypass the JSON stderr envelope contract.
|
||||
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
||||
resp, err := c.DoAPI(ctx, request)
|
||||
if err != nil {
|
||||
@@ -464,23 +489,23 @@ func (c *APIClient) StreamPages(ctx context.Context, request RawApiRequest, onIt
|
||||
return map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}}, false, nil
|
||||
}
|
||||
|
||||
// CheckResponse inspects a Lark API response for business-level errors (non-zero code).
|
||||
//
|
||||
// Deprecated: legacy *output.ExitError wire shape via output.ErrAPI /
|
||||
// ClassifyLarkError (type "api_error" / "permission" / etc). Preserved so
|
||||
// existing callers keep emitting the same envelope until per-domain
|
||||
// migration to typed errors. The identity parameter is reserved for the
|
||||
// stage-2 typed path; stage-1 ignores it.
|
||||
// CheckResponse inspects a Lark API response for business-level errors (non-zero code)
|
||||
// and routes the result through errclass.BuildAPIError so the wire envelope carries
|
||||
// the canonical Category/Subtype + identity-aware extension fields (MissingScopes,
|
||||
// ConsoleURL, etc.) for known Lark codes; unknown codes still surface as
|
||||
// *errs.APIError{Subtype: unknown}.
|
||||
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok || resultMap == nil {
|
||||
return nil
|
||||
}
|
||||
code, _ := util.ToFloat64(resultMap["code"])
|
||||
if code == 0 {
|
||||
if code, _ := util.ToFloat64(resultMap["code"]); code == 0 {
|
||||
return nil
|
||||
}
|
||||
larkCode := int(code)
|
||||
msg, _ := resultMap["msg"].(string)
|
||||
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
||||
cc := errclass.ClassifyContext{Identity: string(identity)}
|
||||
if c != nil && c.Config != nil {
|
||||
cc.Brand = string(c.Config.Brand)
|
||||
cc.AppID = c.Config.AppID
|
||||
}
|
||||
return errclass.BuildAPIError(resultMap, cc)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -18,6 +19,8 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -428,6 +431,39 @@ func TestDoStream_IgnoresBaseHTTPClientTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoStream_TransportFailureSplitsSubtype pins that a streaming-request
|
||||
// transport failure routes through classifyNetworkSubtype rather than emitting
|
||||
// a hardcoded SubtypeNetworkTransport for every cause. Concretely: a DNS
|
||||
// failure must surface as SubtypeNetworkDNS so downstream agents can react
|
||||
// (retry / give up / show recovery hint) without parsing the message text.
|
||||
// Pre-fix, DoStream collapsed every httpClient.Do failure to NetworkTransport,
|
||||
// erasing the timeout / TLS / DNS distinctions the SDK path already preserved.
|
||||
func TestDoStream_TransportFailureSplitsSubtype(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return nil, &net.DNSError{Err: "no such host", Name: "nowhere.invalid"}
|
||||
})
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{Transport: rt},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &staticTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.DoStream(context.Background(), &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/files/file_token/download",
|
||||
}, core.AsBot)
|
||||
if err == nil {
|
||||
t.Fatal("expected DNS error from DoStream transport, got nil")
|
||||
}
|
||||
var netErr *errs.NetworkError
|
||||
if !errors.As(err, &netErr) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
|
||||
}
|
||||
if netErr.Subtype != errs.SubtypeNetworkDNS {
|
||||
t.Errorf("Subtype = %q, want %q (DNS failures must not be classified as generic transport)", netErr.Subtype, errs.SubtypeNetworkDNS)
|
||||
}
|
||||
}
|
||||
|
||||
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||
// auth/credential failure path through resolveAccessToken.
|
||||
type failingTokenResolver struct{}
|
||||
@@ -436,17 +472,93 @@ func (f *failingTokenResolver) ResolveToken(_ context.Context, spec credential.T
|
||||
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
|
||||
}
|
||||
|
||||
// TestDoSDKRequest_AuthFailurePreservesAuthCategory pins the end-to-end
|
||||
// invariant codex caught the day this PR landed: when resolveAccessToken
|
||||
// produces output.ErrAuth ("no access token available for <identity>"),
|
||||
// DoSDKRequest must surface it with the original auth classification —
|
||||
// not silently downgrade it to a network error via the SDK-failure wrap.
|
||||
// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
|
||||
// the missing-token path of resolveAccessToken returns the typed
|
||||
// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy
|
||||
// *output.ExitError envelope.
|
||||
func TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.resolveAccessToken(context.Background(), core.AsUser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no token available, got nil")
|
||||
}
|
||||
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err)
|
||||
}
|
||||
if authErr.Category != errs.CategoryAuthentication {
|
||||
t.Errorf("Category = %v, want %v", authErr.Category, errs.CategoryAuthentication)
|
||||
}
|
||||
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||
}
|
||||
}
|
||||
|
||||
// needAuthTokenResolver returns *internalauth.NeedAuthorizationError to
|
||||
// exercise the P1 regression path: a credential chain that signals
|
||||
// "user must re-authorize" must surface as typed AuthenticationError, not
|
||||
// fall through to the generic err return which WrapDoAPIError would then
|
||||
// wrap as NetworkError (the outer-typed dispatcher gate would then skip
|
||||
// PromoteAuthError and the user would see exit 4 with no auth-login hint).
|
||||
type needAuthTokenResolver struct {
|
||||
userOpenID string
|
||||
}
|
||||
|
||||
func (f *needAuthTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
||||
return nil, &internalauth.NeedAuthorizationError{UserOpenId: f.userOpenID}
|
||||
}
|
||||
|
||||
// TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication
|
||||
// is the codex P1 regression test: without this branch, the credential
|
||||
// chain's NeedAuthorizationError would propagate raw and WrapDoAPIError
|
||||
// would mis-classify it as NetworkError.
|
||||
func TestResolveAccessToken_NeedAuthorization_SurfacesAsTypedAuthentication(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &needAuthTokenResolver{userOpenID: "ou_test_user"}, nil),
|
||||
Config: &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu},
|
||||
}
|
||||
|
||||
_, err := ac.resolveAccessToken(context.Background(), core.AsUser)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when credential chain signals need_user_authorization, got nil")
|
||||
}
|
||||
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
t.Fatalf("expected *errs.AuthenticationError, got %T (%v)", err, err)
|
||||
}
|
||||
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||
}
|
||||
if !strings.Contains(authErr.Message, "need_user_authorization") {
|
||||
t.Errorf("Message must contain the marker 'need_user_authorization' (invariant), got %q", authErr.Message)
|
||||
}
|
||||
// Underlying NeedAuthorizationError preserved in Cause chain so
|
||||
// existing errors.As(&NeedAuthorizationError{}) consumers still match.
|
||||
var needErr *internalauth.NeedAuthorizationError
|
||||
if !errors.As(err, &needErr) {
|
||||
t.Errorf("NeedAuthorizationError not preserved in Cause chain")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError pins the
|
||||
// end-to-end invariant codex caught the day this PR landed: when
|
||||
// resolveAccessToken fails because no token is cached, DoSDKRequest must
|
||||
// surface that as a typed *errs.AuthenticationError — not silently downgrade
|
||||
// it to a network error via the SDK-failure wrap.
|
||||
//
|
||||
// Regression scenario: shortcut path
|
||||
// (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user
|
||||
// identity with no cached token. Pre-fix this surfaced as exit 4/type=network
|
||||
// and routed agents into "check your connection" instead of "log in".
|
||||
func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
|
||||
func TestDoSDKRequest_AuthFailureSurfacesTypedAuthenticationError(t *testing.T) {
|
||||
ac := &APIClient{
|
||||
HTTP: &http.Client{},
|
||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||
@@ -461,22 +573,20 @@ func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected auth error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(err, &authErr) {
|
||||
t.Fatalf("expected *errs.AuthenticationError, got %T (%v) — WrapDoAPIError must pass typed *errs.* through unchanged", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAuth {
|
||||
t.Fatalf("Code = %d, want %d (auth) — confirms ErrAuth was downgraded to network at SDK wrap", exitErr.Code, output.ExitAuth)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
||||
t.Fatalf("Detail.Type = %v, want auth", exitErr.Detail)
|
||||
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
|
||||
// SDK transport errors get the network classification via WrapDoAPIError.
|
||||
// SDK transport errors get the typed network classification via WrapDoAPIError.
|
||||
// io.ErrUnexpectedEOF from a RoundTripper surfaces through net/http as a
|
||||
// *url.Error, which the wrap classifier recognises as a transport error.
|
||||
// *url.Error, which the wrap classifier reaches as the transport-error
|
||||
// fallback (no specific subtype matches — falls back to transport).
|
||||
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
@@ -491,25 +601,29 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error from broken transport, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var netErr *errs.NetworkError
|
||||
if !errors.As(err, &netErr) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitNetwork {
|
||||
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
||||
if netErr.Category != errs.CategoryNetwork {
|
||||
t.Errorf("Category = %v, want %v", netErr.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
||||
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
|
||||
if netErr.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("Subtype = %v, want %v", netErr.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
// io.ErrUnexpectedEOF round-tripping through net/http does not satisfy
|
||||
// any of the specific cause checks; subtype falls back to transport.
|
||||
if output.ExitCodeOf(err) != output.ExitNetwork {
|
||||
t.Errorf("ExitCodeOf = %d, want %d (network)", output.ExitCodeOf(err), output.ExitNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the legacy-envelope contract for
|
||||
// malformed JSON response bodies: WrapJSONResponseParseError emits api_error
|
||||
// (exit 1) with the rawAPIJSONHint, so the pagination / cmd/api / cmd/service
|
||||
// callers always see a JSON stderr envelope instead of a bare "Error: ..."
|
||||
// line. Stage-4 framework-boundary migration will flip this wrapper to typed
|
||||
// *errs.InternalError; until then this test pins the legacy shape so we do
|
||||
// not regress envelope coverage.
|
||||
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for
|
||||
// malformed JSON response bodies: WrapJSONResponseParseError emits
|
||||
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
|
||||
// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see
|
||||
// the typed JSON stderr envelope (exit 5/internal) — wire `type` is
|
||||
// "internal", not the legacy "api_error".
|
||||
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
@@ -529,17 +643,20 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected JSON parse error, got nil")
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
||||
var intErr *errs.InternalError
|
||||
if !errors.As(err, &intErr) {
|
||||
t.Fatalf("expected *errs.InternalError, got %T (%v)", err, err)
|
||||
}
|
||||
if exitErr.Code != output.ExitAPI {
|
||||
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
|
||||
if intErr.Category != errs.CategoryInternal {
|
||||
t.Errorf("Category = %v, want %v", intErr.Category, errs.CategoryInternal)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
||||
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
|
||||
if intErr.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("Subtype = %v, want %v", intErr.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if exitErr.Detail.Hint != rawAPIJSONHint {
|
||||
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
|
||||
if intErr.Hint != rawAPIJSONHint {
|
||||
t.Errorf("Hint = %q, want rawAPIJSONHint preserved", intErr.Hint)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitInternal {
|
||||
t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
|
||||
@@ -41,12 +41,11 @@ func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
|
||||
}, core.AsBot)
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
||||
var netErr *errs.NetworkError
|
||||
if !errors.As(err, &netErr) {
|
||||
t.Fatalf("expected *errs.NetworkError, got %T %v", err, err)
|
||||
}
|
||||
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
||||
if detail["log_id"] != "202605270003" {
|
||||
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
|
||||
if netErr.LogID != "202605270003" {
|
||||
t.Fatalf("LogID = %q, want %q", netErr.LogID, "202605270003")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
@@ -52,12 +53,10 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
}
|
||||
check := opts.CheckError
|
||||
if check == nil {
|
||||
// Stage 1: default check routes through legacy CheckResponse
|
||||
// (output.ErrAPI / ClassifyLarkError). Stage-2+ migration will
|
||||
// switch this to errclass.BuildAPIError so PermissionError carries
|
||||
// MissingScopes / ConsoleURL — at that point a zero-value
|
||||
// *APIClient still works because BuildAPIError short-circuits on
|
||||
// empty AppID, gracefully degrading identity-aware fields.
|
||||
// Default check routes through BuildAPIError, producing typed
|
||||
// *errs.PermissionError / AuthenticationError / etc. A zero-value
|
||||
// *APIClient is safe here because BuildAPIError gracefully degrades
|
||||
// identity-aware fields (ConsoleURL etc.) when AppID is empty.
|
||||
check = func(r interface{}, id core.Identity) error {
|
||||
return (&APIClient{}).CheckResponse(r, id)
|
||||
}
|
||||
@@ -65,9 +64,20 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
|
||||
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
|
||||
// instead of falling through to the binary-save path.
|
||||
// 5xx → typed NetworkError (server/transport tier); 4xx → typed APIError (client error).
|
||||
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
|
||||
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
return output.Errorf(httpExitCode(resp.StatusCode), "http_error", "HTTP %d: %s", resp.StatusCode, body)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer,
|
||||
"HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == 404 {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
// JSON responses: always check for business errors before saving.
|
||||
@@ -102,7 +112,9 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
if opts.JqExpr != "" {
|
||||
return output.ErrValidation("--jq requires a JSON response (got Content-Type: %s)", ct)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--jq requires a JSON response (got Content-Type: %s)", ct).
|
||||
WithParam("--jq")
|
||||
}
|
||||
if opts.OutputPath != "" {
|
||||
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
|
||||
@@ -111,7 +123,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
// No --output: auto-save with derived filename.
|
||||
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
|
||||
return classifySaveErr(err)
|
||||
}
|
||||
fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct)
|
||||
output.PrintJson(opts.Out, meta)
|
||||
@@ -121,12 +133,23 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
|
||||
meta, err := SaveResponse(fio, resp, path)
|
||||
if err != nil {
|
||||
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
|
||||
return classifySaveErr(err)
|
||||
}
|
||||
output.PrintJson(w, meta)
|
||||
return nil
|
||||
}
|
||||
|
||||
// classifySaveErr routes a SaveResponse error to the right typed shape.
|
||||
// Path-validation failures are caller-induced (an unsafe --output path),
|
||||
// so they surface as ValidationError on --output. Mkdir / write failures
|
||||
// are local I/O issues classified as InternalError with SubtypeFileIO.
|
||||
func classifySaveErr(err error) error {
|
||||
if errors.Is(err, fileio.ErrPathValidation) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--output")
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeFileIO, "save response: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
// ── JSON helpers ──
|
||||
|
||||
// IsJSONContentType reports whether the Content-Type header indicates a JSON response.
|
||||
@@ -160,13 +183,13 @@ func SaveResponse(fio fileio.FileIO, resp *larkcore.ApiResp, outputPath string)
|
||||
var we *fileio.WriteError
|
||||
switch {
|
||||
case errors.Is(err, fileio.ErrPathValidation):
|
||||
return nil, fmt.Errorf("unsafe output path: %s", err)
|
||||
return nil, fmt.Errorf("unsafe output path: %w", err)
|
||||
case errors.As(err, &me):
|
||||
return nil, fmt.Errorf("create directory: %s", err)
|
||||
return nil, fmt.Errorf("create directory: %w", err)
|
||||
case errors.As(err, &we):
|
||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
||||
return nil, fmt.Errorf("cannot write file: %w", err)
|
||||
default:
|
||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
||||
return nil, fmt.Errorf("cannot write file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,12 +248,3 @@ func mimeToExt(ct string) string {
|
||||
return ".bin"
|
||||
}
|
||||
}
|
||||
|
||||
// httpExitCode maps HTTP status ranges to CLI exit codes:
|
||||
// 5xx → ExitNetwork (server error), 4xx → ExitAPI (client error).
|
||||
func httpExitCode(status int) int {
|
||||
if status >= 500 {
|
||||
return output.ExitNetwork
|
||||
}
|
||||
return output.ExitAPI
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
@@ -294,9 +295,12 @@ func TestHandleResponse_NonJSONError_404(t *testing.T) {
|
||||
if !strings.Contains(got, "HTTP 404") || !strings.Contains(got, "404 page not found") {
|
||||
t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI (%d) for 4xx, got code: %d", output.ExitAPI, exitErr.Code)
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("expected *errs.APIError, got %T", err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("expected ExitAPI (%d), got %d", output.ExitAPI, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,9 +316,12 @@ func TestHandleResponse_NonJSONError_502(t *testing.T) {
|
||||
if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") {
|
||||
t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got)
|
||||
}
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitNetwork {
|
||||
t.Errorf("expected ExitNetwork (%d) for 5xx, got code: %d", output.ExitNetwork, exitErr.Code)
|
||||
var netErr *errs.NetworkError
|
||||
if !errors.As(err, &netErr) {
|
||||
t.Errorf("expected *errs.NetworkError, got %T", err)
|
||||
}
|
||||
if output.ExitCodeOf(err) != output.ExitNetwork {
|
||||
t.Errorf("expected ExitNetwork (%d) for 5xx, got %d", output.ExitNetwork, output.ExitCodeOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ import (
|
||||
// it hide?".
|
||||
//
|
||||
// Set once at bootstrap time; consumed read-only thereafter.
|
||||
//
|
||||
// Rules is the full set the winning source contributed (one rule for the
|
||||
// common single-rule case, several when a plugin or yaml declares scoped
|
||||
// grants). nil/empty means "no rule applied".
|
||||
type ActivePolicy struct {
|
||||
Rule *platform.Rule
|
||||
Rules []*platform.Rule
|
||||
Source ResolveSource
|
||||
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
||||
}
|
||||
@@ -56,20 +60,26 @@ func GetActive() *ActivePolicy {
|
||||
return cloneActivePolicy(activePolicy)
|
||||
}
|
||||
|
||||
// cloneActivePolicy deep-copies the top-level struct plus the embedded
|
||||
// Rule's slice fields. Other fields (Source, DeniedPaths) are value
|
||||
// types so the struct copy already disjoints them.
|
||||
// cloneActivePolicy deep-copies the top-level struct, the Rules slice, and
|
||||
// each Rule's own slice fields. Other fields (Source, DeniedPaths) are
|
||||
// value types so the struct copy already disjoints them.
|
||||
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *in
|
||||
if in.Rule != nil {
|
||||
rule := *in.Rule
|
||||
rule.Allow = append([]string(nil), in.Rule.Allow...)
|
||||
rule.Deny = append([]string(nil), in.Rule.Deny...)
|
||||
rule.Identities = append([]platform.Identity(nil), in.Rule.Identities...)
|
||||
cp.Rule = &rule
|
||||
if in.Rules != nil {
|
||||
cp.Rules = make([]*platform.Rule, len(in.Rules))
|
||||
for i, r := range in.Rules {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
rule := *r
|
||||
rule.Allow = append([]string(nil), r.Allow...)
|
||||
rule.Deny = append([]string(nil), r.Deny...)
|
||||
rule.Identities = append([]platform.Identity(nil), r.Identities...)
|
||||
cp.Rules[i] = &rule
|
||||
}
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ package cmdpolicy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bmatcuk/doublestar/v4"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -36,16 +37,45 @@ type Decision struct {
|
||||
Reason string // human-readable
|
||||
}
|
||||
|
||||
// Engine evaluates a Rule against the command tree. It is stateless except
|
||||
// for the Rule snapshot it was constructed with.
|
||||
// Engine evaluates a set of Rules against the command tree with OR
|
||||
// semantics: a command is allowed when it satisfies every axis of AT
|
||||
// LEAST ONE rule. It is stateless except for the Rule snapshot it was
|
||||
// constructed with.
|
||||
type Engine struct {
|
||||
rule *platform.Rule
|
||||
rules []*platform.Rule
|
||||
}
|
||||
|
||||
// New returns an Engine bound to a Rule. A nil Rule means "no user-layer
|
||||
// restriction" -- EvaluateOne always returns Allowed=true.
|
||||
// New returns an Engine bound to a single Rule. A nil Rule means "no
|
||||
// user-layer restriction" -- EvaluateOne always returns Allowed=true.
|
||||
// It is the ergonomic single-rule constructor, kept so existing callers
|
||||
// (and the single-rule decision path) stay byte-for-byte unchanged.
|
||||
func New(rule *platform.Rule) *Engine {
|
||||
return &Engine{rule: rule}
|
||||
if rule == nil {
|
||||
return &Engine{}
|
||||
}
|
||||
return &Engine{rules: []*platform.Rule{rule}}
|
||||
}
|
||||
|
||||
// NewSet returns an Engine bound to a set of Rules evaluated with OR
|
||||
// semantics. An empty/nil slice means "no user-layer restriction". nil
|
||||
// entries are dropped so callers may pass a slice with gaps without a
|
||||
// separate filter step.
|
||||
//
|
||||
// With exactly one rule the behaviour is identical to New(rule): the
|
||||
// rejection Decision is returned verbatim. With multiple rules a command
|
||||
// rejected by all of them gets the aggregate reason_code
|
||||
// "no_matching_rule" (see mergeDenials).
|
||||
func NewSet(rules []*platform.Rule) *Engine {
|
||||
cleaned := make([]*platform.Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r != nil {
|
||||
cleaned = append(cleaned, r)
|
||||
}
|
||||
}
|
||||
if len(cleaned) == 0 {
|
||||
return &Engine{}
|
||||
}
|
||||
return &Engine{rules: cleaned}
|
||||
}
|
||||
|
||||
// EvaluateAll walks the command tree and evaluates every **runnable**
|
||||
@@ -81,27 +111,29 @@ func (e *Engine) EvaluateAll(root *cobra.Command) map[string]Decision {
|
||||
}
|
||||
|
||||
// EvaluateOne returns the user-layer decision for a single command. Always
|
||||
// Allowed=true when the engine has no Rule.
|
||||
// Allowed=true when the engine has no Rule. With multiple rules the
|
||||
// decision is the OR over per-rule evaluations: the command is allowed as
|
||||
// soon as one rule grants it; if every rule rejects it, the rejections are
|
||||
// merged (see mergeDenials).
|
||||
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
if e.rule == nil {
|
||||
if len(e.rules) == 0 {
|
||||
return Decision{Allowed: true}
|
||||
}
|
||||
r := e.rule
|
||||
path := CanonicalPath(cmd)
|
||||
|
||||
if IsDiagnosticPath(path) {
|
||||
return Decision{Allowed: true}
|
||||
}
|
||||
|
||||
// A registered Rule expresses intent over the closed risk taxonomy
|
||||
// (read / write / high-risk-write). Two ways a command can fall
|
||||
// outside that taxonomy:
|
||||
// risk_invalid is a property of the COMMAND's own annotation (the
|
||||
// annotation exists but is a typo / not in the closed taxonomy
|
||||
// read / write / high-risk-write). It is independent of any Rule and
|
||||
// is always fail-closed regardless of AllowUnannotated -- a typo is a
|
||||
// code bug, not a migration phase. So it is checked once up front,
|
||||
// before the per-rule OR loop, and short-circuits to deny.
|
||||
//
|
||||
// - "absent" (no risk_level annotation) — fail-closed by default,
|
||||
// but Rule.AllowUnannotated=true opts out for gradual adoption.
|
||||
// - "invalid" (annotation exists but is a typo / not in the
|
||||
// closed enum) — always fail-closed regardless of
|
||||
// AllowUnannotated. Typo is a code bug, not a migration phase.
|
||||
// The "absent" case (no risk_level annotation at all) is per-rule:
|
||||
// each rule's AllowUnannotated decides, so it lives inside evalRule.
|
||||
cmdRiskStr, hasRisk := cmdmeta.Risk(cmd)
|
||||
cmdRisk := platform.Risk(cmdRiskStr)
|
||||
var (
|
||||
@@ -117,7 +149,31 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
Reason: fmt.Sprintf("invalid risk %q; did you mean %q?", cmdRiskStr, suggestRisk(cmdRiskStr)),
|
||||
}
|
||||
}
|
||||
} else if !r.AllowUnannotated {
|
||||
}
|
||||
|
||||
// OR across rules: the first rule that fully grants the command wins.
|
||||
denials := make([]Decision, 0, len(e.rules))
|
||||
for _, r := range e.rules {
|
||||
d := evalRule(r, path, cmd, hasRisk, cmdRisk, cmdRank, cmdRankOk)
|
||||
if d.Allowed {
|
||||
return Decision{Allowed: true}
|
||||
}
|
||||
denials = append(denials, d)
|
||||
}
|
||||
return mergeDenials(e.rules, denials)
|
||||
}
|
||||
|
||||
// evalRule applies one Rule's four-axis AND filter to a command whose
|
||||
// risk annotation has already been parsed by EvaluateOne (risk_invalid is
|
||||
// handled there). cmdRankOk is false only when the command is unannotated
|
||||
// (hasRisk=false); a present-but-invalid risk never reaches here. Returns
|
||||
// Allowed=true only when the command clears every axis of this rule.
|
||||
func evalRule(r *platform.Rule, path string, cmd *cobra.Command, hasRisk bool, cmdRisk platform.Risk, cmdRank int, cmdRankOk bool) Decision {
|
||||
// Unannotated gate: fail-closed unless THIS rule opts out. A command
|
||||
// with no risk_level annotation can still be granted by a rule that
|
||||
// sets AllowUnannotated=true (gradual-adoption opt-in); other rules in
|
||||
// the set reject it here and the OR moves on.
|
||||
if !hasRisk && !r.AllowUnannotated {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "risk_not_annotated",
|
||||
@@ -125,7 +181,9 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
}
|
||||
}
|
||||
|
||||
// Axis 1: Deny has priority.
|
||||
// Axis 1: Deny has priority. Note OR semantics scope a rule's Deny to
|
||||
// that rule only -- it cannot veto another rule's Allow. A command to
|
||||
// block everywhere must be denied (or simply not allowed) by every rule.
|
||||
if matched, ok := firstMatch(r.Deny, path); ok {
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
@@ -171,6 +229,34 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||
return Decision{Allowed: true}
|
||||
}
|
||||
|
||||
// mergeDenials collapses the per-rule rejections into a single Decision
|
||||
// for a command that no rule granted. denials is parallel to rules (same
|
||||
// order, one entry per rule, all Allowed=false).
|
||||
//
|
||||
// With exactly one rule the original rejection is returned verbatim, so
|
||||
// single-rule envelopes are byte-for-byte identical to the pre-multi-rule
|
||||
// behaviour (reason_code / reason unchanged). With multiple rules the
|
||||
// rejection is the aggregate reason_code "no_matching_rule"; its Reason
|
||||
// enumerates each rule's own rejection for debugging.
|
||||
func mergeDenials(rules []*platform.Rule, denials []Decision) Decision {
|
||||
if len(denials) == 1 {
|
||||
return denials[0]
|
||||
}
|
||||
parts := make([]string, len(denials))
|
||||
for i, d := range denials {
|
||||
name := rules[i].Name
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("#%d", i)
|
||||
}
|
||||
parts[i] = fmt.Sprintf("%s: %s", name, d.ReasonCode)
|
||||
}
|
||||
return Decision{
|
||||
Allowed: false,
|
||||
ReasonCode: "no_matching_rule",
|
||||
Reason: fmt.Sprintf("no rule grants this command (%s)", strings.Join(parts, "; ")),
|
||||
}
|
||||
}
|
||||
|
||||
// BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed
|
||||
// by canonical path. It performs the parent-group aggregation defined in
|
||||
// the tech doc: a non-runnable parent whose every runnable descendant is
|
||||
|
||||
@@ -398,6 +398,93 @@ func TestEvaluate_unknownIdentitiesIsAllow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Multi-rule (OR) semantics ---
|
||||
|
||||
// Two scoped rules (docs read-only, im writable) are OR-combined: a
|
||||
// command is allowed when it satisfies ANY rule. This is the headline
|
||||
// multi-rule use case -- different command groups need different risk
|
||||
// ceilings within one policy.
|
||||
func TestEvaluate_multiRuleOR(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.NewSet([]*platform.Rule{
|
||||
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
||||
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"},
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
// docs/+fetch (read) clears docs-ro.
|
||||
if !got["docs/+fetch"].Allowed {
|
||||
t.Errorf("docs/+fetch should be allowed by docs-ro")
|
||||
}
|
||||
// im/+send (write) clears im-rw even though docs-ro rejects it.
|
||||
if !got["im/+send"].Allowed {
|
||||
t.Errorf("im/+send (write) should be allowed by im-rw")
|
||||
}
|
||||
// docs/+update (write) exceeds docs-ro's read ceiling AND is outside
|
||||
// im-rw's allow list -> rejected by both -> no_matching_rule.
|
||||
if got["docs/+update"].Allowed {
|
||||
t.Fatalf("docs/+update should be denied: read-only in docs, not allowed in im")
|
||||
}
|
||||
if rc := got["docs/+update"].ReasonCode; rc != "no_matching_rule" {
|
||||
t.Errorf("docs/+update ReasonCode = %q, want no_matching_rule", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// Identity can differ per rule: docs limited to user, im open to bot.
|
||||
// This is the second half of the requirement -- some commands restrict
|
||||
// identity, others allow the bot identity.
|
||||
func TestEvaluate_multiRulePerRuleIdentity(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.NewSet([]*platform.Rule{
|
||||
{Name: "docs-user", Allow: []string{"docs/**"}, MaxRisk: "write", Identities: []platform.Identity{"user"}},
|
||||
{Name: "im-bot", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"bot"}},
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
|
||||
// docs/+update identities=[user] -> docs-user grants.
|
||||
if !got["docs/+update"].Allowed {
|
||||
t.Errorf("docs/+update (user) should be allowed by docs-user")
|
||||
}
|
||||
// im/+send identities=[bot] -> im-bot grants.
|
||||
if !got["im/+send"].Allowed {
|
||||
t.Errorf("im/+send (bot) should be allowed by im-bot")
|
||||
}
|
||||
// docs/+delete-doc is high-risk-write -> exceeds both ceilings -> denied.
|
||||
if got["docs/+delete-doc"].Allowed {
|
||||
t.Errorf("docs/+delete-doc (high-risk-write) should be denied by both rules")
|
||||
}
|
||||
}
|
||||
|
||||
// NewSet with a single rule must behave exactly like New: the per-rule
|
||||
// rejection (not the aggregate no_matching_rule) is preserved so the
|
||||
// single-rule envelope is unchanged.
|
||||
func TestEvaluate_newSetSingleRuleKeepsReason(t *testing.T) {
|
||||
root := buildTree()
|
||||
e := cmdpolicy.NewSet([]*platform.Rule{
|
||||
{Allow: []string{"docs/**"}},
|
||||
})
|
||||
got := e.EvaluateAll(root)
|
||||
if got["im/+send"].Allowed {
|
||||
t.Fatalf("im/+send should be denied by docs-only rule")
|
||||
}
|
||||
if rc := got["im/+send"].ReasonCode; rc != "domain_not_allowed" {
|
||||
t.Errorf("single-rule reason must be preserved verbatim, got %q want domain_not_allowed", rc)
|
||||
}
|
||||
}
|
||||
|
||||
// NewSet drops nil entries; an all-nil/empty set means "no restriction".
|
||||
func TestNewSet_emptyAndNilMeansNoRestriction(t *testing.T) {
|
||||
root := buildTree()
|
||||
for _, rules := range [][]*platform.Rule{nil, {}, {nil}} {
|
||||
got := cmdpolicy.NewSet(rules).EvaluateAll(root)
|
||||
for path, d := range got {
|
||||
if !d.Allowed {
|
||||
t.Fatalf("empty/nil rule set must allow all, got deny for %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply must install denyStubs only on Layer="policy" entries. A
|
||||
// "strict_mode" denial in the same map must be left for
|
||||
// applyStrictModeDenials in cmd/.
|
||||
|
||||
@@ -33,44 +33,69 @@ type PluginRule struct {
|
||||
|
||||
type Sources struct {
|
||||
PluginRules []PluginRule
|
||||
YAMLRule *platform.Rule
|
||||
YAMLRules []*platform.Rule
|
||||
YAMLPath string
|
||||
}
|
||||
|
||||
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one is permitted")
|
||||
var ErrMultipleRestricts = errors.New("multiple plugins called Restrict; only one plugin may own the policy")
|
||||
|
||||
// Resolve picks by precedence: plugin > yaml > none. Pure function; load
|
||||
// yaml via LoadYAMLPolicy first. Winner is validated.
|
||||
func Resolve(s Sources) (*platform.Rule, ResolveSource, error) {
|
||||
if len(s.PluginRules) > 1 {
|
||||
names := make([]string, len(s.PluginRules))
|
||||
for i, pr := range s.PluginRules {
|
||||
names[i] = pr.PluginName
|
||||
}
|
||||
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names)
|
||||
// Resolve picks by precedence: plugin > yaml > none, returning the full
|
||||
// rule set the winning source contributes. Pure function; load yaml via
|
||||
// LoadYAMLPolicy first. Every returned rule is validated.
|
||||
//
|
||||
// Multi-rule semantics (single owner): one plugin may contribute several
|
||||
// rules (each a scoped grant, OR-combined by the engine), but two or more
|
||||
// DISTINCT plugins contributing rules is still a configuration error --
|
||||
// the resolver aborts so independent plugins cannot silently widen each
|
||||
// other's policy. yaml may likewise carry several rules under "rules:".
|
||||
func Resolve(s Sources) ([]*platform.Rule, ResolveSource, error) {
|
||||
owners := distinctOwners(s.PluginRules)
|
||||
if len(owners) > 1 {
|
||||
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, owners)
|
||||
}
|
||||
|
||||
if len(s.PluginRules) == 1 {
|
||||
pr := s.PluginRules[0]
|
||||
if err := ValidateRule(pr.Rule); err != nil {
|
||||
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
|
||||
if len(s.PluginRules) > 0 {
|
||||
rules := make([]*platform.Rule, 0, len(s.PluginRules))
|
||||
for _, pr := range s.PluginRules {
|
||||
if err := ValidateRule(pr.Rule); err != nil {
|
||||
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
|
||||
}
|
||||
rules = append(rules, pr.Rule)
|
||||
}
|
||||
return pr.Rule, ResolveSource{Kind: SourcePlugin, Name: pr.PluginName}, nil
|
||||
return rules, ResolveSource{Kind: SourcePlugin, Name: owners[0]}, nil
|
||||
}
|
||||
|
||||
if s.YAMLRule != nil {
|
||||
if err := ValidateRule(s.YAMLRule); err != nil {
|
||||
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
|
||||
if len(s.YAMLRules) > 0 {
|
||||
for _, r := range s.YAMLRules {
|
||||
if err := ValidateRule(r); err != nil {
|
||||
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
|
||||
}
|
||||
}
|
||||
return s.YAMLRule, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
|
||||
return s.YAMLRules, ResolveSource{Kind: SourceYAML, Name: s.YAMLPath}, nil
|
||||
}
|
||||
|
||||
return nil, ResolveSource{Kind: SourceNone}, nil
|
||||
}
|
||||
|
||||
// distinctOwners returns the unique plugin names contributing a rule, in
|
||||
// first-seen order. A single plugin contributing N rules collapses to one
|
||||
// owner; that is the case the single-owner check below permits.
|
||||
func distinctOwners(prs []PluginRule) []string {
|
||||
seen := map[string]bool{}
|
||||
owners := make([]string, 0, len(prs))
|
||||
for _, pr := range prs {
|
||||
if !seen[pr.PluginName] {
|
||||
seen[pr.PluginName] = true
|
||||
owners = append(owners, pr.PluginName)
|
||||
}
|
||||
}
|
||||
return owners
|
||||
}
|
||||
|
||||
// LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent,
|
||||
// so callers can pass the result straight into Sources.YAMLRule.
|
||||
func LoadYAMLPolicy(path string) (*platform.Rule, error) {
|
||||
// so callers can pass the result straight into Sources.YAMLRules. A
|
||||
// present file yields one or more rules (see yaml.Parse).
|
||||
func LoadYAMLPolicy(path string) ([]*platform.Rule, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -84,9 +109,9 @@ func LoadYAMLPolicy(path string) (*platform.Rule, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read policy yaml %q: %w", path, err)
|
||||
}
|
||||
rule, err := pyaml.Parse(data)
|
||||
rules, err := pyaml.Parse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("policy yaml %q: %w", path, err)
|
||||
}
|
||||
return rule, nil
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
@@ -21,23 +21,45 @@ func TestResolve_singlePluginWins(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if got != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
|
||||
if len(got) != 1 || got[0] != rule || src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
|
||||
t.Fatalf("Resolve = (%v, %+v)", got, src)
|
||||
}
|
||||
}
|
||||
|
||||
// A single plugin may contribute several rules (each a scoped grant). They
|
||||
// are all returned, in registration order, under one plugin source.
|
||||
func TestResolve_singlePluginMultipleRules(t *testing.T) {
|
||||
r1 := &platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"}
|
||||
r2 := &platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write"}
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: []cmdpolicy.PluginRule{
|
||||
{PluginName: "secaudit", Rule: r1},
|
||||
{PluginName: "secaudit", Rule: r2},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if len(got) != 2 || got[0] != r1 || got[1] != r2 {
|
||||
t.Fatalf("expected both rules in order, got %v", got)
|
||||
}
|
||||
if src.Kind != cmdpolicy.SourcePlugin || src.Name != "secaudit" {
|
||||
t.Fatalf("source = %+v, want plugin:secaudit", src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_pluginShadowsYaml(t *testing.T) {
|
||||
pluginRule := &platform.Rule{Name: "from-plugin"}
|
||||
yamlRule := &platform.Rule{Name: "from-yaml"}
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
|
||||
YAMLRule: yamlRule,
|
||||
YAMLRules: []*platform.Rule{yamlRule},
|
||||
YAMLPath: "/some/policy.yml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if got.Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin {
|
||||
if len(got) != 1 || got[0].Name != "from-plugin" || src.Kind != cmdpolicy.SourcePlugin {
|
||||
t.Fatalf("plugin should shadow yaml, got %+v / %+v", got, src)
|
||||
}
|
||||
}
|
||||
@@ -45,13 +67,13 @@ func TestResolve_pluginShadowsYaml(t *testing.T) {
|
||||
func TestResolve_yamlWhenNoPlugin(t *testing.T) {
|
||||
yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"}
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
YAMLRule: yamlRule,
|
||||
YAMLPath: "/some/policy.yml",
|
||||
YAMLRules: []*platform.Rule{yamlRule},
|
||||
YAMLPath: "/some/policy.yml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if got.Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
|
||||
if len(got) != 1 || got[0].Name != "from-yaml" || src.Kind != cmdpolicy.SourceYAML {
|
||||
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
|
||||
}
|
||||
if src.Name != "/some/policy.yml" {
|
||||
@@ -59,19 +81,36 @@ func TestResolve_yamlWhenNoPlugin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// yaml may also carry several rules under "rules:"; all are returned.
|
||||
func TestResolve_yamlMultipleRules(t *testing.T) {
|
||||
r1 := &platform.Rule{Name: "a", MaxRisk: "read"}
|
||||
r2 := &platform.Rule{Name: "b", MaxRisk: "write"}
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
YAMLRules: []*platform.Rule{r1, r2},
|
||||
YAMLPath: "/some/policy.yml",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if len(got) != 2 || src.Kind != cmdpolicy.SourceYAML {
|
||||
t.Fatalf("expected both yaml rules, got %v / %+v", got, src)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_emptyEverythingIsNone(t *testing.T) {
|
||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve err: %v", err)
|
||||
}
|
||||
if got != nil || src.Kind != cmdpolicy.SourceNone {
|
||||
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
|
||||
if len(got) != 0 || src.Kind != cmdpolicy.SourceNone {
|
||||
t.Fatalf("expected (empty, SourceNone), got (%v, %+v)", got, src)
|
||||
}
|
||||
}
|
||||
|
||||
// Two plugins both contributing a Rule must produce the typed error so
|
||||
// the bootstrap pipeline aborts (hard-constraint #7).
|
||||
func TestResolve_multipleRestrictIsError(t *testing.T) {
|
||||
// Two DISTINCT plugins both contributing a Rule must produce the typed
|
||||
// error so the bootstrap pipeline aborts (single-owner invariant): one
|
||||
// plugin cannot silently widen another plugin's policy.
|
||||
func TestResolve_multipleRestrictPluginsIsError(t *testing.T) {
|
||||
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||
PluginRules: []cmdpolicy.PluginRule{
|
||||
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
|
||||
@@ -84,26 +123,26 @@ func TestResolve_multipleRestrictIsError(t *testing.T) {
|
||||
}
|
||||
|
||||
// LoadYAMLPolicy: missing file returns (nil, nil) silently so callers
|
||||
// can pass the result straight into Sources.YAMLRule without special-
|
||||
// can pass the result straight into Sources.YAMLRules without special-
|
||||
// casing not-exist.
|
||||
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
|
||||
rule, err := cmdpolicy.LoadYAMLPolicy(missing)
|
||||
rules, err := cmdpolicy.LoadYAMLPolicy(missing)
|
||||
if err != nil {
|
||||
t.Fatalf("missing yaml should not error, got %v", err)
|
||||
}
|
||||
if rule != nil {
|
||||
t.Fatalf("missing yaml should return nil rule, got %+v", rule)
|
||||
if rules != nil {
|
||||
t.Fatalf("missing yaml should return nil rules, got %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
|
||||
rule, err := cmdpolicy.LoadYAMLPolicy("")
|
||||
rules, err := cmdpolicy.LoadYAMLPolicy("")
|
||||
if err != nil {
|
||||
t.Fatalf("empty path should not error, got %v", err)
|
||||
}
|
||||
if rule != nil {
|
||||
t.Fatalf("empty path should return nil rule, got %+v", rule)
|
||||
if rules != nil {
|
||||
t.Fatalf("empty path should return nil rules, got %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,11 +152,11 @@ func TestLoadYAMLPolicy_parsesValid(t *testing.T) {
|
||||
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
rule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||
rules, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadYAMLPolicy err: %v", err)
|
||||
}
|
||||
if rule == nil || rule.Name != "from-yaml" {
|
||||
t.Fatalf("expected rule with name=from-yaml, got %+v", rule)
|
||||
if len(rules) != 1 || rules[0].Name != "from-yaml" {
|
||||
t.Fatalf("expected one rule with name=from-yaml, got %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package yaml parses a Rule from yaml bytes. It is kept separate from the
|
||||
// public extension/platform package so that platform stays free of yaml
|
||||
// library dependencies -- plugins constructing a Rule in Go code never
|
||||
// import yaml, only the file loader does.
|
||||
// Package yaml parses one or more Rules from yaml bytes. It is kept
|
||||
// separate from the public extension/platform package so that platform
|
||||
// stays free of yaml library dependencies -- plugins constructing a Rule
|
||||
// in Go code never import yaml, only the file loader does.
|
||||
//
|
||||
// This package does **structural** parsing only (yaml syntax + unknown-field
|
||||
// rejection). Semantic validation (valid MaxRisk enum, valid identity
|
||||
@@ -23,9 +23,9 @@ import (
|
||||
"github.com/larksuite/cli/extension/platform"
|
||||
)
|
||||
|
||||
// schema is the internal yaml-tagged shape. Mirrors platform.Rule but lives
|
||||
// here so the public Rule has no yaml tag baggage.
|
||||
type schema struct {
|
||||
// ruleSchema is the internal yaml-tagged shape of one rule. Mirrors
|
||||
// platform.Rule but lives here so the public Rule has no yaml tag baggage.
|
||||
type ruleSchema struct {
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Allow []string `yaml:"allow,omitempty"`
|
||||
@@ -35,35 +35,45 @@ type schema struct {
|
||||
AllowUnannotated bool `yaml:"allow_unannotated,omitempty"`
|
||||
}
|
||||
|
||||
// Parse decodes yaml bytes into a *platform.Rule. Unknown fields are
|
||||
// rejected so an old binary cannot silently ignore new schema additions
|
||||
// (forward-compat safeguard).
|
||||
// fileSchema is the top-level document shape. Two mutually-exclusive
|
||||
// layouts are accepted:
|
||||
//
|
||||
// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is
|
||||
// the caller's responsibility -- run the result through
|
||||
// internal/cmdpolicy.ValidateRule before handing it to the engine.
|
||||
func Parse(data []byte) (*platform.Rule, error) {
|
||||
var s schema
|
||||
dec := gopkgyaml.NewDecoder(bytesReader(data))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&s); err != nil {
|
||||
return nil, fmt.Errorf("parse policy yaml: %w", err)
|
||||
}
|
||||
// - a single rule written with flat top-level fields (the historical
|
||||
// layout; the inlined ruleSchema), or
|
||||
// - a "rules:" list of rule objects (multi-rule layout).
|
||||
//
|
||||
// Mixing the two (flat fields AND a rules: list in the same file) is a
|
||||
// configuration error -- Parse rejects it rather than guessing intent.
|
||||
//
|
||||
// Rules is a pointer so Parse can tell "rules: key absent" (nil) apart
|
||||
// from "rules: present but empty" (non-nil, len 0). The latter is a
|
||||
// foot-gun -- a config generator that renders an empty list would
|
||||
// otherwise yield a single all-zero Rule that lets every annotated
|
||||
// command through -- so Parse rejects it outright.
|
||||
type fileSchema struct {
|
||||
ruleSchema `yaml:",inline"`
|
||||
Rules *[]ruleSchema `yaml:"rules,omitempty"`
|
||||
}
|
||||
|
||||
// Reject multi-document input: yaml.v3 only decodes one document
|
||||
// per call, so a stray "---" followed by another document would
|
||||
// silently drop the trailing rule.
|
||||
var extra schema
|
||||
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed")
|
||||
// isZero reports whether every field is its zero value. Used to detect
|
||||
// the flat-fields-plus-rules: mixing error.
|
||||
func (s ruleSchema) isZero() bool {
|
||||
return s.Name == "" && s.Description == "" &&
|
||||
len(s.Allow) == 0 && len(s.Deny) == 0 &&
|
||||
s.MaxRisk == "" && len(s.Identities) == 0 && !s.AllowUnannotated
|
||||
}
|
||||
|
||||
func (s ruleSchema) toRule() *platform.Rule {
|
||||
// Leave Identities nil when absent (omitempty-style), matching how the
|
||||
// Allow/Deny slices arrive nil from yaml. A zero-length but non-nil
|
||||
// slice is behaviourally identical to the engine but trips
|
||||
// reflect.DeepEqual in tests and reads as "explicitly empty".
|
||||
var idents []platform.Identity
|
||||
if len(s.Identities) > 0 {
|
||||
idents = make([]platform.Identity, len(s.Identities))
|
||||
for i, id := range s.Identities {
|
||||
idents[i] = platform.Identity(id)
|
||||
}
|
||||
return nil, fmt.Errorf("parse policy yaml: %w", err)
|
||||
}
|
||||
|
||||
idents := make([]platform.Identity, len(s.Identities))
|
||||
for i, id := range s.Identities {
|
||||
idents[i] = platform.Identity(id)
|
||||
}
|
||||
return &platform.Rule{
|
||||
Name: s.Name,
|
||||
@@ -73,5 +83,53 @@ func Parse(data []byte) (*platform.Rule, error) {
|
||||
MaxRisk: platform.Risk(s.MaxRisk),
|
||||
Identities: idents,
|
||||
AllowUnannotated: s.AllowUnannotated,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Parse decodes yaml bytes into one or more *platform.Rule. Unknown fields
|
||||
// are rejected so an old binary cannot silently ignore new schema additions
|
||||
// (forward-compat safeguard).
|
||||
//
|
||||
// The result always has at least one element: a flat-fields document
|
||||
// yields a single rule (possibly an all-zero "no restriction" rule), and a
|
||||
// "rules:" list yields one rule per entry.
|
||||
//
|
||||
// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is
|
||||
// the caller's responsibility -- run each result through
|
||||
// internal/cmdpolicy.ValidateRule before handing it to the engine.
|
||||
func Parse(data []byte) ([]*platform.Rule, error) {
|
||||
var s fileSchema
|
||||
dec := gopkgyaml.NewDecoder(bytesReader(data))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&s); err != nil {
|
||||
return nil, fmt.Errorf("parse policy yaml: %w", err)
|
||||
}
|
||||
|
||||
// Reject multi-document input: yaml.v3 only decodes one document
|
||||
// per call, so a stray "---" followed by another document would
|
||||
// silently drop the trailing rule.
|
||||
var extra fileSchema
|
||||
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed")
|
||||
}
|
||||
return nil, fmt.Errorf("parse policy yaml: %w", err)
|
||||
}
|
||||
|
||||
if s.Rules != nil {
|
||||
if len(*s.Rules) == 0 {
|
||||
return nil, fmt.Errorf("parse policy yaml: 'rules:' is present but empty; remove the key, or list at least one rule")
|
||||
}
|
||||
if !s.ruleSchema.isZero() {
|
||||
return nil, fmt.Errorf("parse policy yaml: top-level rule fields cannot be combined with a 'rules:' list; move every rule under 'rules:'")
|
||||
}
|
||||
out := make([]*platform.Rule, 0, len(*s.Rules))
|
||||
for _, rs := range *s.Rules {
|
||||
out = append(out, rs.toRule())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Backward-compatible single top-level rule (flat fields).
|
||||
return []*platform.Rule{s.ruleSchema.toRule()}, nil
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ max_risk: read
|
||||
identities:
|
||||
- user
|
||||
`)
|
||||
rule, err := pyaml.Parse(data)
|
||||
rules, err := pyaml.Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
@@ -36,8 +36,59 @@ identities:
|
||||
MaxRisk: "read",
|
||||
Identities: []platform.Identity{"user"},
|
||||
}
|
||||
if !reflect.DeepEqual(rule, want) {
|
||||
t.Fatalf("rule = %+v, want %+v", rule, want)
|
||||
// A flat top-level rule yields exactly one element (backward compat).
|
||||
if !reflect.DeepEqual(rules, []*platform.Rule{want}) {
|
||||
t.Fatalf("rules = %+v, want single %+v", rules, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A "rules:" list yields one platform.Rule per entry, in order. This is
|
||||
// the multi-rule layout: each rule is a scoped grant the engine
|
||||
// OR-combines.
|
||||
func TestParse_rulesList(t *testing.T) {
|
||||
data := []byte(`
|
||||
rules:
|
||||
- name: docs-ro
|
||||
allow: [docs/**]
|
||||
max_risk: read
|
||||
- name: im-rw
|
||||
allow: [im/**]
|
||||
max_risk: write
|
||||
identities: [user, bot]
|
||||
`)
|
||||
rules, err := pyaml.Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
want := []*platform.Rule{
|
||||
{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: "read"},
|
||||
{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: "write", Identities: []platform.Identity{"user", "bot"}},
|
||||
}
|
||||
if !reflect.DeepEqual(rules, want) {
|
||||
t.Fatalf("rules = %+v, want %+v", rules, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A "rules:" key that is present but empty is a foot-gun: an empty list
|
||||
// would otherwise fall through to a single all-zero Rule that allows
|
||||
// every annotated command ("looks like a policy, enforces almost
|
||||
// nothing"). Parse must reject it outright instead.
|
||||
func TestParse_rejectsEmptyRulesList(t *testing.T) {
|
||||
if _, err := pyaml.Parse([]byte("rules: []\n")); err == nil {
|
||||
t.Fatalf("Parse should reject a present-but-empty 'rules:' list")
|
||||
}
|
||||
}
|
||||
|
||||
// Mixing top-level flat rule fields with a rules: list is ambiguous and
|
||||
// must be rejected rather than silently picking one.
|
||||
func TestParse_rejectsFlatPlusRulesMix(t *testing.T) {
|
||||
data := []byte(`
|
||||
name: top-level
|
||||
rules:
|
||||
- name: nested
|
||||
`)
|
||||
if _, err := pyaml.Parse(data); err == nil {
|
||||
t.Fatalf("Parse should reject mixing top-level fields with a rules: list")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,15 +103,15 @@ name: agent-readonly
|
||||
max_risk: read
|
||||
allow_unannotated: true
|
||||
`)
|
||||
rule, err := pyaml.Parse(data)
|
||||
rules, err := pyaml.Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
if !rule.AllowUnannotated {
|
||||
if !rules[0].AllowUnannotated {
|
||||
t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)")
|
||||
}
|
||||
if rule.MaxRisk != "read" || rule.Name != "agent-readonly" {
|
||||
t.Errorf("other fields lost: %+v", rule)
|
||||
if rules[0].MaxRisk != "read" || rules[0].Name != "agent-readonly" {
|
||||
t.Errorf("other fields lost: %+v", rules[0])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,11 +122,11 @@ func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) {
|
||||
name: x
|
||||
max_risk: read
|
||||
`)
|
||||
rule, err := pyaml.Parse(data)
|
||||
rules, err := pyaml.Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failed: %v", err)
|
||||
}
|
||||
if rule.AllowUnannotated {
|
||||
if rules[0].AllowUnannotated {
|
||||
t.Fatalf("AllowUnannotated must default to false when key is absent")
|
||||
}
|
||||
}
|
||||
@@ -96,12 +147,12 @@ mystery_field: oh no
|
||||
// structural yaml; an invalid max_risk passes through (validation happens
|
||||
// downstream).
|
||||
func TestParse_doesNotValidateSemantics(t *testing.T) {
|
||||
rule, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
|
||||
rules, err := pyaml.Parse([]byte("max_risk: nuclear\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("structural parse should succeed, got %v", err)
|
||||
}
|
||||
if rule.MaxRisk != "nuclear" {
|
||||
t.Fatalf("MaxRisk = %q, want passed through as-is", rule.MaxRisk)
|
||||
if rules[0].MaxRisk != "nuclear" {
|
||||
t.Fatalf("MaxRisk = %q, want passed through as-is", rules[0].MaxRisk)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ package cmdutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -13,13 +12,13 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// Factory holds shared dependencies injected into every command.
|
||||
@@ -129,11 +128,18 @@ func (f *Factory) CheckIdentity(as core.Identity, supported []string) error {
|
||||
}
|
||||
list := strings.Join(supported, ", ")
|
||||
if f.IdentityAutoDetected {
|
||||
return output.ErrValidation(
|
||||
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s",
|
||||
as, list, supported[0])
|
||||
base := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s",
|
||||
as, list).
|
||||
WithParam("--as")
|
||||
if len(supported) > 0 {
|
||||
return base.WithHint("use --as %s", supported[0])
|
||||
}
|
||||
return base
|
||||
}
|
||||
return fmt.Errorf("--as %s is not supported, this command only supports: %s", as, list)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--as %s is not supported, this command only supports: %s", as, list).
|
||||
WithParam("--as")
|
||||
}
|
||||
|
||||
// ResolveStrictMode returns the effective strict mode by reading
|
||||
@@ -161,9 +167,9 @@ func (f *Factory) ResolveStrictMode(ctx context.Context) core.StrictMode {
|
||||
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
|
||||
mode := f.ResolveStrictMode(ctx)
|
||||
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
||||
"if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()).
|
||||
WithHint("if the user explicitly wants to switch policy, see `lark-cli config strict-mode --help` (confirm with the user before switching; switching does NOT require re-bind)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -202,9 +208,9 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RequireBuiltinCredentialProvider returns a structured error (exit 2, code
|
||||
// "external_provider") when an extension provider is actively managing credentials.
|
||||
// Intended for use as PersistentPreRunE on the auth and config parent commands.
|
||||
// RequireBuiltinCredentialProvider returns a typed validation error when an
|
||||
// extension provider is actively managing credentials. Intended for use as
|
||||
// PersistentPreRunE on the auth and config parent commands.
|
||||
//
|
||||
// Returns nil when:
|
||||
// - f.Credential is nil (test environments without credential setup)
|
||||
@@ -220,10 +226,7 @@ func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command
|
||||
if provName == "" {
|
||||
return nil
|
||||
}
|
||||
return output.ErrWithHint(
|
||||
output.ExitValidation,
|
||||
"external_provider",
|
||||
fmt.Sprintf("%q is not supported: credentials are provided externally and do not support interactive management", command),
|
||||
"If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.",
|
||||
)
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"%q is not supported: credentials are provided externally and do not support interactive management", command).
|
||||
WithHint("If another tool or method for authorization is available in this environment, try that. Otherwise, ask the user to set up credentials through the appropriate channel.")
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
_ "github.com/larksuite/cli/internal/vfs/localfileio" // register default FileIO provider
|
||||
)
|
||||
|
||||
@@ -102,15 +102,15 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error {
|
||||
|
||||
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||
return sync.OnceValues(func() (*http.Client, error) {
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
|
||||
var transport http.RoundTripper = util.SharedTransport()
|
||||
transport = &RetryTransport{Base: transport}
|
||||
transport = &SecurityHeaderTransport{Base: transport}
|
||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
||||
transport = wrapWithExtension(transport)
|
||||
var rt http.RoundTripper = transport.Shared()
|
||||
rt = &RetryTransport{Base: rt}
|
||||
rt = &SecurityHeaderTransport{Base: rt}
|
||||
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||
rt = wrapWithExtension(rt)
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
Transport: rt,
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
}
|
||||
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
lark.WithHeaders(BaseSecurityHeaders()),
|
||||
}
|
||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||
Transport: buildSDKTransport(),
|
||||
CheckRedirect: safeRedirectPolicy,
|
||||
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
||||
}
|
||||
|
||||
func buildSDKTransport() http.RoundTripper {
|
||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
||||
var sdkTransport http.RoundTripper = transport.Shared()
|
||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/credential"
|
||||
@@ -179,14 +180,15 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
|
||||
f.IdentityAutoDetected = true
|
||||
|
||||
err := f.CheckIdentity(core.AsUser, []string{"bot"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolved identity") {
|
||||
t.Errorf("expected 'resolved identity' in error, got: %v", err)
|
||||
if !strings.Contains(ve.Message, "resolved identity") {
|
||||
t.Errorf("expected 'resolved identity' in message, got: %v", ve.Message)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "hint: use --as bot") {
|
||||
t.Errorf("expected hint in error, got: %v", err)
|
||||
if !strings.Contains(ve.Hint, "use --as bot") {
|
||||
t.Errorf("expected hint to suggest --as bot, got: %v", ve.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,20 +424,17 @@ func TestRequireBuiltinCredentialProvider_BlocksExternalProvider(t *testing.T) {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var exitErr *output.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if exitErr.Code != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
||||
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
||||
t.Errorf("error type field = %v, want %q", exitErr.Detail, "external_provider")
|
||||
}
|
||||
if exitErr.Detail.Message == "" {
|
||||
if ve.Message == "" {
|
||||
t.Error("expected non-empty message")
|
||||
}
|
||||
if exitErr.Detail.Hint == "" {
|
||||
if ve.Hint == "" {
|
||||
t.Error("expected non-empty hint")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ const (
|
||||
|
||||
officialModulePath = "github.com/larksuite/cli"
|
||||
|
||||
agentTraceMaxLen = 256
|
||||
agentTraceMaxLen = 1024
|
||||
)
|
||||
|
||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
exttransport "github.com/larksuite/cli/extension/transport"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/transport"
|
||||
)
|
||||
|
||||
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
func (t *RetryTransport) delay() time.Duration {
|
||||
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
return transport.Fallback().RoundTrip(req)
|
||||
}
|
||||
|
||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
||||
@@ -87,7 +87,7 @@ func (t *BuildHeaderTransport) RoundTrip(req *http.Request) (*http.Response, err
|
||||
if t.Base != nil {
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
return util.FallbackTransport().RoundTrip(req)
|
||||
return transport.Fallback().RoundTrip(req)
|
||||
}
|
||||
|
||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
||||
if t.Base != nil {
|
||||
return t.Base
|
||||
}
|
||||
return util.FallbackTransport()
|
||||
return transport.Fallback()
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper.
|
||||
|
||||
@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
|
||||
|
||||
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
||||
// the transport still sets X-Cli-Build and routes the request through
|
||||
// util.FallbackTransport rather than panicking. This covers the fallback
|
||||
// transport.Fallback rather than panicking. This covers the fallback
|
||||
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
||||
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
||||
var receivedBuild string
|
||||
|
||||
@@ -4,21 +4,50 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/keychain"
|
||||
|
||||
extcred "github.com/larksuite/cli/extension/credential"
|
||||
)
|
||||
|
||||
// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the
|
||||
// canonical typed error. The TAT mint endpoint reports invalid credentials
|
||||
// with two distinct codes:
|
||||
//
|
||||
// - 10003: bad app_id format or non-existent app_id ("invalid param")
|
||||
// - 10014: invalid app_secret ("app secret invalid")
|
||||
//
|
||||
// Both surface as CategoryConfig/InvalidClient from the user's perspective —
|
||||
// the configured credentials cannot mint a tenant access token. 10014 is
|
||||
// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543).
|
||||
// 10003 is NOT globally mapped because in other Lark endpoints it carries
|
||||
// unrelated semantics (e.g. task API uses 10003 for permission denied), so
|
||||
// the override stays local to this TAT call site instead of leaking into the
|
||||
// shared codemeta table.
|
||||
func classifyTATResponseCode(code int, msg, brand, appID string) error {
|
||||
if code == 10003 {
|
||||
return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg).
|
||||
WithCode(code).
|
||||
WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient))
|
||||
}
|
||||
return errclass.BuildAPIError(map[string]any{
|
||||
"code": code,
|
||||
"msg": msg,
|
||||
}, errclass.ClassifyContext{
|
||||
Brand: brand,
|
||||
AppID: appID,
|
||||
})
|
||||
}
|
||||
|
||||
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||
type DefaultAccountProvider struct {
|
||||
keychain func() keychain.KeychainAccess
|
||||
@@ -135,42 +164,9 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ep := core.ResolveEndpoints(acct.Brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": acct.AppID,
|
||||
"app_secret": acct.AppSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
token, err := FetchTAT(ctx, httpClient, acct.Brand, acct.AppID, acct.AppSecret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg)
|
||||
}
|
||||
return &TokenResult{Token: result.TenantAccessToken}, nil
|
||||
return &TokenResult{Token: token}, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
package credential
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
||||
@@ -15,3 +18,68 @@ func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
||||
func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT
|
||||
// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient.
|
||||
// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint,
|
||||
// which from the user's perspective is the same actionable failure as 10014
|
||||
// ("app secret invalid") — both mean the configured credentials cannot mint a
|
||||
// tenant access token. The global codemeta intentionally does not map 10003
|
||||
// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task
|
||||
// API uses it for permission denied), so the override is local to this site.
|
||||
func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) {
|
||||
err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10003")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Error("Hint must be non-empty so the user gets a recovery action")
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still
|
||||
// goes through the global BuildAPIError path (codemeta entry) so the override
|
||||
// for 10003 does not regress the existing mapping.
|
||||
func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) {
|
||||
err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for code=10014")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10014 {
|
||||
t.Errorf("Code = %d, want 10014", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside
|
||||
// the credential set fall through to the generic BuildAPIError fallback
|
||||
// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional.
|
||||
func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) {
|
||||
err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected non-nil error for unmapped code")
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if errors.As(err, &cfgErr) {
|
||||
t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
70
internal/credential/tat_fetch.go
Normal file
70
internal/credential/tat_fetch.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// FetchTAT performs a single HTTP POST to mint a tenant access token with the
|
||||
// given credentials. It does not read configuration or keychain, so callers
|
||||
// that already hold plaintext credentials (e.g. the post-`config init` probe)
|
||||
// can validate them without a second keychain round-trip.
|
||||
//
|
||||
// A non-zero TAT response code means the server inspected the payload and
|
||||
// rejected the credentials; FetchTAT returns the canonical typed error from
|
||||
// classifyTATResponseCode — the SAME classification doResolveTAT (and thus
|
||||
// every token-resolving command) produces, so callers see one consistent
|
||||
// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.).
|
||||
// Transport, HTTP-status and JSON-parse failures are returned raw (untyped),
|
||||
// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic
|
||||
// credential rejection apart from upstream/transport noise.
|
||||
//
|
||||
// The caller owns the context timeout.
|
||||
func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) {
|
||||
ep := core.ResolveEndpoints(brand)
|
||||
url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal"
|
||||
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"app_id": appID,
|
||||
"app_secret": appSecret,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal TAT request: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
TenantAccessToken string `json:"tenant_access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse TAT response: %w", err)
|
||||
}
|
||||
if result.Code != 0 {
|
||||
return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID)
|
||||
}
|
||||
return result.TenantAccessToken, nil
|
||||
}
|
||||
237
internal/credential/tat_fetch_test.go
Normal file
237
internal/credential/tat_fetch_test.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package credential
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// stubRoundTripper lets us assert request shape and return canned responses.
|
||||
type stubRoundTripper struct {
|
||||
gotReq *http.Request
|
||||
gotBody string
|
||||
respCode int
|
||||
respBody string
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
s.gotReq = req
|
||||
if req.Body != nil {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
s.gotBody = string(b)
|
||||
}
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: s.respCode,
|
||||
Body: io.NopCloser(strings.NewReader(s.respBody)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestFetchTAT_Success(t *testing.T) {
|
||||
rt := &stubRoundTripper{
|
||||
respCode: 200,
|
||||
respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`,
|
||||
}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if token != "t-abc" {
|
||||
t.Errorf("token = %q, want t-abc", token)
|
||||
}
|
||||
if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" {
|
||||
t.Errorf("url = %s", rt.gotReq.URL.String())
|
||||
}
|
||||
if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) {
|
||||
t.Errorf("request body missing credentials: %s", rt.gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 10003 (bad / non-existent app_id, "invalid param") is classified locally by
|
||||
// classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same
|
||||
// typed error doResolveTAT (and thus every token-resolving command) returns.
|
||||
func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 10003")
|
||||
}
|
||||
if token != "" {
|
||||
t.Errorf("token = %q, want empty", token)
|
||||
}
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Category != errs.CategoryConfig {
|
||||
t.Errorf("Category = %q, want %q", cfgErr.Category, errs.CategoryConfig)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Code != 10003 {
|
||||
t.Errorf("Code = %d, want 10003", cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 10014 ("app secret invalid") — the most common real-world rejection (real
|
||||
// app_id + wrong secret) — is globally mapped in codemeta to
|
||||
// CategoryConfig / SubtypeInvalidClient via BuildAPIError.
|
||||
func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("error not *errs.ConfigError: %T %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 {
|
||||
t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Any non-zero body code is a deterministic server-side rejection, so it
|
||||
// always yields a typed error (errs.IsTyped). An unrecognized code falls back
|
||||
// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe
|
||||
// caller still surfaces it rather than silently swallowing.
|
||||
func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 99999")
|
||||
}
|
||||
if !errs.IsTyped(err) {
|
||||
t.Fatalf("expected a typed errs.* error, got %T %v", err, err)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Errorf("unknown code should fall back to *errs.APIError, got %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it
|
||||
// must stay UNTYPED so a probe caller treats it as upstream noise and stays
|
||||
// silent.
|
||||
func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) {
|
||||
for _, code := range []int{401, 403, 500, 503} {
|
||||
rt := &stubRoundTripper{respCode: code, respBody: `whatever`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatalf("HTTP %d: expected error", code)
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("HTTP %d: must be UNTYPED (ambiguous), got typed %T %v", code, err, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_TransportError_Untyped(t *testing.T) {
|
||||
sentinel := errors.New("network down")
|
||||
rt := &stubRoundTripper{err: sentinel}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("transport error must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Errorf("error chain missing sentinel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_ParseError_Untyped(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `not json`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
_, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x")
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("parse error must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_BrandRouting(t *testing.T) {
|
||||
tests := []struct {
|
||||
brand core.LarkBrand
|
||||
wantURL string
|
||||
}{
|
||||
{core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
{core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.brand), func(t *testing.T) {
|
||||
rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`}
|
||||
hc := &http.Client{Transport: rt}
|
||||
if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := rt.gotReq.URL.String(); got != tc.wantURL {
|
||||
t.Errorf("url = %s, want %s", got, tc.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchTAT_ContextCanceled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
rt := &urlRewriteRT{base: srv.URL}
|
||||
hc := &http.Client{Transport: rt}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // pre-canceled
|
||||
|
||||
_, err := FetchTAT(ctx, hc, core.BrandFeishu, "a", "b")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for canceled context")
|
||||
}
|
||||
if errs.IsTyped(err) {
|
||||
t.Errorf("canceled context must be UNTYPED, got typed %T", err)
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("error chain missing context.Canceled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// urlRewriteRT forwards requests to a fixed base URL (test server).
|
||||
type urlRewriteRT struct{ base string }
|
||||
|
||||
func (r *urlRewriteRT) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
newURL := r.base + req.URL.Path
|
||||
req2, err := http.NewRequestWithContext(req.Context(), req.Method, newURL, req.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req2.Header = req.Header
|
||||
return http.DefaultTransport.RoundTrip(req2)
|
||||
}
|
||||
@@ -20,4 +20,8 @@ const (
|
||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||
|
||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||
|
||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
|
||||
CliCAPath = "LARKSUITE_CLI_CA_PATH"
|
||||
)
|
||||
|
||||
@@ -20,6 +20,7 @@ type ClassifyContext struct {
|
||||
Brand string // "feishu" | "lark" — drives console_url host
|
||||
AppID string // placed in console_url
|
||||
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
|
||||
LarkCmd string // e.g. "drive +delete" — used as Action fallback on CategoryConfirmation arm
|
||||
}
|
||||
|
||||
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
|
||||
@@ -35,7 +36,7 @@ type ClassifyContext struct {
|
||||
// Network → *errs.NetworkError
|
||||
// Internal → *errs.InternalError
|
||||
// Confirmation → *errs.ConfirmationRequiredError
|
||||
// default (CategoryAPI) → *errs.APIError (Detail preserves raw response)
|
||||
// default (CategoryAPI) → *errs.APIError (catch-all for classified Lark business errors)
|
||||
//
|
||||
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
|
||||
// CategoryAPI + SubtypeUnknown.
|
||||
@@ -80,6 +81,17 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
LogID: logID,
|
||||
Retryable: meta.Retryable,
|
||||
}
|
||||
// Upstream-provided diagnostic URL (resp.error.troubleshooter). Lifted
|
||||
// universally before the category switch so every classified typed
|
||||
// error surfaces it when present. The remaining contents of resp["error"]
|
||||
// (permission_violations.subject, data.challenge_url, data.hint) are
|
||||
// either lifted into category-specific typed extension fields below or
|
||||
// intentionally dropped as redundant with the typed envelope.
|
||||
if errBlock, ok := resp["error"].(map[string]any); ok {
|
||||
if ts, _ := errBlock["troubleshooter"].(string); ts != "" {
|
||||
base.Troubleshooter = ts
|
||||
}
|
||||
}
|
||||
|
||||
switch meta.Category {
|
||||
case errs.CategoryAuthorization:
|
||||
@@ -87,7 +99,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
case errs.CategoryAuthentication:
|
||||
return &errs.AuthenticationError{Problem: base}
|
||||
case errs.CategoryConfig:
|
||||
return &errs.ConfigError{Problem: base}
|
||||
return buildConfigError(base)
|
||||
case errs.CategoryPolicy:
|
||||
return buildSecurityPolicyError(base, resp)
|
||||
case errs.CategoryValidation:
|
||||
@@ -97,9 +109,40 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
||||
case errs.CategoryInternal:
|
||||
return &errs.InternalError{Problem: base}
|
||||
case errs.CategoryConfirmation:
|
||||
return &errs.ConfirmationRequiredError{Problem: base}
|
||||
// Risk + Action are non-omitempty wire fields. Derive from
|
||||
// CodeMeta when available; otherwise emit RiskUnknown +
|
||||
// ctx.LarkCmd placeholder so the envelope is never wire-invalid.
|
||||
risk := meta.Risk
|
||||
if risk == "" {
|
||||
risk = errs.RiskUnknown
|
||||
}
|
||||
action := meta.Action
|
||||
if action == "" {
|
||||
action = cc.LarkCmd
|
||||
}
|
||||
if action == "" {
|
||||
action = "unknown"
|
||||
}
|
||||
return &errs.ConfirmationRequiredError{
|
||||
Problem: base,
|
||||
Risk: risk,
|
||||
Action: action,
|
||||
}
|
||||
case errs.CategoryAPI:
|
||||
base.Hint = APIHint(base.Subtype) // "" for subtypes without a context-free default
|
||||
return &errs.APIError{Problem: base}
|
||||
default:
|
||||
return &errs.APIError{Problem: base, Detail: resp}
|
||||
// Fail closed: an unrecognized Category routes to InternalError
|
||||
// instead of emitting an empty Problem on the wire.
|
||||
return &errs.InternalError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryInternal,
|
||||
Subtype: errs.SubtypeSDKError,
|
||||
Code: base.Code,
|
||||
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
|
||||
LogID: base.LogID,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,7 +192,7 @@ func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.Securit
|
||||
|
||||
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
||||
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
|
||||
// the two will collapse when the auth transport adopts BuildAPIError in stage 4.
|
||||
// the two collapse once the auth transport adopts BuildAPIError directly.
|
||||
func isHTTPSURL(rawURL string) bool {
|
||||
if rawURL == "" {
|
||||
return false
|
||||
@@ -167,47 +210,158 @@ func stringFromAny(v any) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// buildConfigError enriches a typed ConfigError with the canonical
|
||||
// per-subtype recovery hint before returning it, so the wire envelope
|
||||
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
||||
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
||||
p.Hint = ConfigHint(p.Subtype)
|
||||
return &errs.ConfigError{Problem: p}
|
||||
}
|
||||
|
||||
// ConfigHint returns the canonical per-subtype recovery hint for a typed
|
||||
// ConfigError emitted via BuildAPIError.
|
||||
func ConfigHint(subtype errs.Subtype) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeInvalidClient:
|
||||
return "run `lark-cli config init` to set valid app_id and app_secret"
|
||||
case errs.SubtypeNotConfigured:
|
||||
return "run `lark-cli config init` to set up app_id and app_secret"
|
||||
case errs.SubtypeInvalidConfig:
|
||||
return "check the config file for syntax errors; rerun `lark-cli config init` to reset"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// APIHint returns the canonical per-subtype recovery hint for a typed APIError
|
||||
// emitted via BuildAPIError, for API subtypes whose recovery is context-free.
|
||||
// Context-specific guidance (e.g. a command's flags, an API's own quota) is
|
||||
// layered on by the caller after BuildAPIError returns and overrides this.
|
||||
func APIHint(subtype errs.Subtype) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeConflict:
|
||||
return "retry later and avoid concurrent duplicate requests on the same resource"
|
||||
case errs.SubtypeCrossTenant:
|
||||
return "operate on source and target within the same tenant and region/unit"
|
||||
case errs.SubtypeCrossBrand:
|
||||
return "operate on source and target within the same brand environment"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||
missing := extractMissingScopes(resp)
|
||||
identity := cc.Identity
|
||||
if identity == "" {
|
||||
identity = "user"
|
||||
}
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype)
|
||||
return &errs.PermissionError{
|
||||
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
||||
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
||||
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||
permErr := &errs.PermissionError{
|
||||
Problem: p,
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
ConsoleURL: ConsoleURL(cc.Brand, cc.AppID, missing),
|
||||
}
|
||||
// ConsoleURL is the developer-console deep-link an app developer follows to
|
||||
// apply for a missing scope. That action only resolves SubtypeAppScopeNotApplied,
|
||||
// which is bot-perspective. The other authorization subtypes route to a
|
||||
// different actor: SubtypeMissingScope / SubtypeTokenScopeInsufficient /
|
||||
// SubtypeUserUnauthorized recover via `lark-cli auth login`; SubtypeAppUnavailable
|
||||
// / SubtypeAppDisabled require tenant admin. Carrying ConsoleURL on those
|
||||
// envelopes is dead weight and risks pointing an end user at a console they
|
||||
// cannot modify; the URL is still computed so the hint composer can use it
|
||||
// where appropriate.
|
||||
if p.Subtype == errs.SubtypeAppScopeNotApplied {
|
||||
permErr.ConsoleURL = consoleURL
|
||||
}
|
||||
return permErr
|
||||
}
|
||||
|
||||
// PermissionHint returns an actionable next-step string for a permission
|
||||
// error. User identity with a missing user-scope is recovered by re-running
|
||||
// `auth login --scope ...`; bot identity or app-level scope errors are
|
||||
// recovered by enabling scopes in the open-platform console. The subtype
|
||||
// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied)
|
||||
// where re-authentication will not help regardless of the caller identity.
|
||||
// CanonicalPermissionMessage returns the CLI-side canonical wording for a
|
||||
// typed PermissionError, preserving the Lark official-API phrasing
|
||||
// ("access denied" / "unauthorized" / "token has no permission") and
|
||||
// enhancing it with CLI context (app ID, missing scope list). Subtypes
|
||||
// outside the known set fall through to fallback so the upstream message
|
||||
// is preserved.
|
||||
func CanonicalPermissionMessage(subtype errs.Subtype, appID string, missing []string, fallback string) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeAppScopeNotApplied:
|
||||
if len(missing) > 0 {
|
||||
scopes := strings.Join(missing, ", ")
|
||||
if appID != "" {
|
||||
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s): %s", appID, scopes)
|
||||
}
|
||||
return fmt.Sprintf("access denied: app has not applied for the required scope(s): %s", scopes)
|
||||
}
|
||||
if appID != "" {
|
||||
return fmt.Sprintf("access denied: app %s has not applied for the required scope(s)", appID)
|
||||
}
|
||||
return "access denied: app has not applied for the required scope(s)"
|
||||
case errs.SubtypeMissingScope:
|
||||
if len(missing) > 0 {
|
||||
return fmt.Sprintf("unauthorized: user authorization does not cover the required scope(s): %s", strings.Join(missing, ", "))
|
||||
}
|
||||
return "unauthorized: user authorization does not cover the required scope"
|
||||
case errs.SubtypeTokenScopeInsufficient:
|
||||
return "token has no permission for this operation; required scope is missing"
|
||||
case errs.SubtypeUserUnauthorized:
|
||||
return "access denied for this operation; possible causes: missing scope, missing user authorization, or restricted by tenant policy"
|
||||
case errs.SubtypeAppUnavailable:
|
||||
if appID != "" {
|
||||
return fmt.Sprintf("unauthorized app: app %s is not properly installed in this tenant", appID)
|
||||
}
|
||||
return "unauthorized app: app is not properly installed in this tenant"
|
||||
case errs.SubtypeAppDisabled:
|
||||
if appID != "" {
|
||||
return fmt.Sprintf("app %s is not in use in this tenant (currently disabled)", appID)
|
||||
}
|
||||
return "app is not in use in this tenant (currently disabled)"
|
||||
case errs.SubtypePermissionDenied:
|
||||
return "user lacks permission for the requested resource"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// PermissionHint returns the canonical per-subtype recovery hint for a typed
|
||||
// PermissionError. The hint distinguishes authorization subtypes routing
|
||||
// to different recovery paths: developer console for app_scope_not_applied,
|
||||
// user re-login for missing_scope / token_scope_insufficient / user_unauthorized,
|
||||
// and tenant admin for app_unavailable / app_disabled. The subtype
|
||||
// argument is the primary discriminator; identity is retained for the
|
||||
// generic permission_denied fallback so callers that do not yet route on
|
||||
// subtype still get a sensible hint.
|
||||
//
|
||||
// Exported so direct construction sites (cmd/service/service.go's
|
||||
// checkServiceScopes) can produce hints that match the dispatcher path
|
||||
// byte-for-byte instead of hand-rolling divergent strings.
|
||||
func PermissionHint(missing []string, identity string, subtype errs.Subtype) string {
|
||||
// app_scope_not_enabled means the scope has not been granted at the
|
||||
// app (developer console) level — re-authenticating cannot fix it,
|
||||
// so route every caller identity to the console hint.
|
||||
useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied
|
||||
if len(missing) == 0 {
|
||||
if useConsole {
|
||||
return "check the app's scope grant in the Lark open platform console"
|
||||
func PermissionHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) string {
|
||||
switch subtype {
|
||||
case errs.SubtypeAppScopeNotApplied:
|
||||
if consoleURL != "" {
|
||||
return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)
|
||||
}
|
||||
return "ensure the calling identity has been granted the required scopes"
|
||||
return "the app developer must apply for the required scope(s) at the developer console"
|
||||
case errs.SubtypeMissingScope:
|
||||
if len(missing) > 0 {
|
||||
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authorize the user with the updated scope set", strings.Join(missing, " "))
|
||||
}
|
||||
return "run `lark-cli auth login` to re-authorize the user with the updated scope set"
|
||||
case errs.SubtypeTokenScopeInsufficient:
|
||||
return "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued"
|
||||
case errs.SubtypeUserUnauthorized:
|
||||
return "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy"
|
||||
case errs.SubtypeAppUnavailable:
|
||||
return "ask the tenant admin to check the app's install status in the Lark admin console"
|
||||
case errs.SubtypeAppDisabled:
|
||||
return "ask the tenant admin to re-enable the app in the Lark admin console"
|
||||
case errs.SubtypePermissionDenied:
|
||||
who := "this user"
|
||||
if identity == "bot" {
|
||||
who = "this bot"
|
||||
}
|
||||
return fmt.Sprintf("check the resource owner has granted access to %s", who)
|
||||
}
|
||||
scopes := strings.Join(missing, " ")
|
||||
if useConsole {
|
||||
return fmt.Sprintf("the app is missing required scope(s): %s. Open the app's open platform console and add them.", scopes)
|
||||
}
|
||||
return fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` to re-authenticate with the missing scope(s)", scopes)
|
||||
return "check the calling identity has the required scope"
|
||||
}
|
||||
|
||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
||||
|
||||
136
internal/errclass/classify_internal_test.go
Normal file
136
internal/errclass/classify_internal_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestBuildAPIError_CategoryConfirmationFillsRiskAction pins fail-closed
|
||||
// behaviour: a code mapped to CategoryConfirmation MUST yield a
|
||||
// ConfirmationRequiredError whose Risk + Action are non-empty even when the
|
||||
// CodeMeta itself carries no Risk/Action hints. Risk falls back to
|
||||
// RiskUnknown; Action falls back to ctx.LarkCmd.
|
||||
func TestBuildAPIError_CategoryConfirmationFillsRiskAction(t *testing.T) {
|
||||
const stubCode = 99999991
|
||||
codeMeta[stubCode] = CodeMeta{
|
||||
Category: errs.CategoryConfirmation,
|
||||
Subtype: errs.SubtypeConfirmationRequired,
|
||||
}
|
||||
t.Cleanup(func() { delete(codeMeta, stubCode) })
|
||||
|
||||
resp := map[string]any{"code": stubCode, "msg": "confirmation required"}
|
||||
ctx := ClassifyContext{
|
||||
Brand: "feishu",
|
||||
AppID: "cli_test",
|
||||
Identity: "user",
|
||||
LarkCmd: "drive +delete",
|
||||
}
|
||||
err := BuildAPIError(resp, ctx)
|
||||
var confirmErr *errs.ConfirmationRequiredError
|
||||
if !errors.As(err, &confirmErr) {
|
||||
t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err)
|
||||
}
|
||||
if confirmErr.Risk == "" {
|
||||
t.Error("Risk empty; arm must fail-closed with RiskUnknown")
|
||||
}
|
||||
if confirmErr.Risk != errs.RiskUnknown {
|
||||
t.Errorf("Risk = %q, want %q (CodeMeta carried no Risk hint)",
|
||||
confirmErr.Risk, errs.RiskUnknown)
|
||||
}
|
||||
if confirmErr.Action == "" {
|
||||
t.Error("Action empty; arm must fail-closed with command name from ClassifyContext")
|
||||
}
|
||||
if confirmErr.Action != "drive +delete" {
|
||||
t.Errorf("Action = %q, want %q (ctx.LarkCmd fallback)",
|
||||
confirmErr.Action, "drive +delete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints pins that when
|
||||
// CodeMeta carries explicit Risk + Action, the dispatcher uses them rather
|
||||
// than falling back to RiskUnknown / ctx.LarkCmd.
|
||||
func TestBuildAPIError_CategoryConfirmationPrefersCodeMetaHints(t *testing.T) {
|
||||
const stubCode = 99999992
|
||||
codeMeta[stubCode] = CodeMeta{
|
||||
Category: errs.CategoryConfirmation,
|
||||
Subtype: errs.SubtypeConfirmationRequired,
|
||||
Risk: errs.RiskHighRiskWrite,
|
||||
Action: "wiki:delete-space",
|
||||
}
|
||||
t.Cleanup(func() { delete(codeMeta, stubCode) })
|
||||
|
||||
resp := map[string]any{"code": stubCode, "msg": "confirmation required"}
|
||||
ctx := ClassifyContext{LarkCmd: "drive +delete"}
|
||||
err := BuildAPIError(resp, ctx)
|
||||
var confirmErr *errs.ConfirmationRequiredError
|
||||
if !errors.As(err, &confirmErr) {
|
||||
t.Fatalf("expected *ConfirmationRequiredError, got %T: %v", err, err)
|
||||
}
|
||||
if confirmErr.Risk != errs.RiskHighRiskWrite {
|
||||
t.Errorf("Risk = %q, want %q (CodeMeta hint should win)",
|
||||
confirmErr.Risk, errs.RiskHighRiskWrite)
|
||||
}
|
||||
if confirmErr.Action != "wiki:delete-space" {
|
||||
t.Errorf("Action = %q, want %q (CodeMeta hint should win)",
|
||||
confirmErr.Action, "wiki:delete-space")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_UnknownCategoryRoutesToInternalError pins fail-closed
|
||||
// behaviour: an unrecognized Category routes to InternalError instead of
|
||||
// emitting an empty Problem on the wire.
|
||||
func TestBuildAPIError_UnknownCategoryRoutesToInternalError(t *testing.T) {
|
||||
const stubCode = 99999993
|
||||
codeMeta[stubCode] = CodeMeta{
|
||||
Category: errs.Category("totally_unknown_category"),
|
||||
Subtype: errs.SubtypeUnknown,
|
||||
}
|
||||
t.Cleanup(func() { delete(codeMeta, stubCode) })
|
||||
|
||||
resp := map[string]any{"code": stubCode, "msg": "weird"}
|
||||
err := BuildAPIError(resp, ClassifyContext{})
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("expected *InternalError, got %T: %v", err, err)
|
||||
}
|
||||
if ie.Category != errs.CategoryInternal {
|
||||
t.Errorf("Category = %q, want %q", ie.Category, errs.CategoryInternal)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeSDKError {
|
||||
t.Errorf("Subtype = %q, want %q", ie.Subtype, errs.SubtypeSDKError)
|
||||
}
|
||||
if ie.Code != stubCode {
|
||||
t.Errorf("Code = %d, want %d (raw Lark code should propagate)", ie.Code, stubCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_ConfigInvalidClient_HasHint pins that when a
|
||||
// CategoryConfig response (Lark code 10014 — "app secret invalid") flows
|
||||
// through BuildAPIError, the resulting *ConfigError MUST carry the canonical
|
||||
// recovery hint pointing the user at `lark-cli config init`.
|
||||
func TestBuildAPIError_ConfigInvalidClient_HasHint(t *testing.T) {
|
||||
const code = 10014
|
||||
resp := map[string]any{"code": code, "msg": "app secret invalid"}
|
||||
ctx := ClassifyContext{Brand: "feishu", AppID: "cli_test", Identity: "bot"}
|
||||
|
||||
err := BuildAPIError(resp, ctx)
|
||||
var cfgErr *errs.ConfigError
|
||||
if !errors.As(err, &cfgErr) {
|
||||
t.Fatalf("expected *ConfigError, got %T: %v", err, err)
|
||||
}
|
||||
if cfgErr.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if cfgErr.Hint == "" {
|
||||
t.Errorf("Hint is empty; canonical hint required for invalid_client")
|
||||
}
|
||||
if !strings.Contains(cfgErr.Hint, "lark-cli config init") {
|
||||
t.Errorf("Hint should reference `lark-cli config init`; got %q", cfgErr.Hint)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,22 @@ func missingScopeResp(scope string) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// appScopeNotAppliedResp builds the Lark response shape for code 99991672
|
||||
// ("the app has not applied for the required scope(s)"). Used by tests that
|
||||
// exercise the bot-perspective ConsoleURL attachment path, which the
|
||||
// dispatcher restricts to SubtypeAppScopeNotApplied only.
|
||||
func appScopeNotAppliedResp(scope string) map[string]any {
|
||||
return map[string]any{
|
||||
"code": 99991672,
|
||||
"msg": "app scope not applied",
|
||||
"error": map[string]any{
|
||||
"permission_violations": []any{
|
||||
map[string]any{"subject": scope},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
|
||||
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
|
||||
t.Errorf("nil resp should return nil error, got %v", got)
|
||||
@@ -95,8 +111,8 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
{"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"},
|
||||
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
|
||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 3, "PermissionError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.Subtype("task_permission_denied"), 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryValidation, errs.Subtype("task_invalid_params"), 2, "ValidationError"},
|
||||
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
||||
@@ -129,29 +145,92 @@ func TestBuildAPIError_ExitCodeMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_ValidationRoutesToValidationError pins that code 1470400
|
||||
// (taskCodeMeta → CategoryValidation) produces *errs.ValidationError, not
|
||||
// the default *errs.APIError. The dispatcher must read codeMeta.Category and
|
||||
// route accordingly so the embedded Problem.Category matches the wire type.
|
||||
func TestBuildAPIError_ValidationRoutesToValidationError(t *testing.T) {
|
||||
// TestBuildAPIError_TaskInvalidParamsRoutesToAPIError pins that code 1470400
|
||||
// (Lark API-side parameter rejection) routes to *errs.APIError + CategoryAPI
|
||||
// + SubtypeInvalidParameters. CategoryValidation is reserved for CLI-side
|
||||
// (caller-side) flag/arg validation, never reachable from API responses;
|
||||
// classify_test pins the API-side classification here so a regression that
|
||||
// re-introduces the misclassification fails fast.
|
||||
func TestBuildAPIError_TaskInvalidParamsRoutesToAPIError(t *testing.T) {
|
||||
resp := map[string]any{"code": 1470400, "msg": "bad params"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for code 1470400")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
||||
}
|
||||
if _, isAPI := err.(*errs.APIError); isAPI {
|
||||
t.Fatalf("unexpected *errs.APIError fallthrough (F2 regression): %T", err)
|
||||
var ae *errs.APIError
|
||||
if !errors.As(err, &ae) {
|
||||
t.Fatalf("expected *errs.APIError, got %T", err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
if p.Category != errs.CategoryAPI {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryAPI)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidParameters {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_TroubleshooterLiftedOnAPIArm pins that BuildAPIError lifts
|
||||
// resp.error.troubleshooter into Problem.Troubleshooter when the response
|
||||
// routes to the catch-all CategoryAPI arm. troubleshooter is the only
|
||||
// resp.error field with genuinely non-redundant content vs typed envelope
|
||||
// fields; the rest (permission_violations.subject, log_id, challenge_url) is
|
||||
// already lifted by category-specific paths.
|
||||
func TestBuildAPIError_TroubleshooterLiftedOnAPIArm(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 1470400,
|
||||
"msg": "bad params",
|
||||
"error": map[string]any{
|
||||
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/x",
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/x" {
|
||||
t.Errorf("Troubleshooter = %q, want passthrough", p.Troubleshooter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_TroubleshooterLiftedOnPermissionArm pins that
|
||||
// troubleshooter surfaces on classified non-API arms too — BuildAPIError lifts
|
||||
// it before the category switch so PermissionError / ConfigError / etc. inherit
|
||||
// the same wire vocab.
|
||||
func TestBuildAPIError_TroubleshooterLiftedOnPermissionArm(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": 99991679,
|
||||
"msg": "missing scope",
|
||||
"error": map[string]any{
|
||||
"troubleshooter": "https://open.feishu.cn/document/troubleshoot/scope",
|
||||
"permission_violations": []any{map[string]any{"subject": "docx:document"}},
|
||||
},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Identity: "user"})
|
||||
var pe *errs.PermissionError
|
||||
if !errors.As(err, &pe) {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
}
|
||||
if pe.Troubleshooter != "https://open.feishu.cn/document/troubleshoot/scope" {
|
||||
t.Errorf("Troubleshooter = %q, want lifted on PermissionError", pe.Troubleshooter)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildAPIError_TroubleshooterAbsent pins that Troubleshooter stays empty
|
||||
// when the upstream response omits it — wire envelope must omit the field.
|
||||
func TestBuildAPIError_TroubleshooterAbsent(t *testing.T) {
|
||||
resp := map[string]any{"code": 1470400, "msg": "bad params"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatal("ProblemOf returned !ok")
|
||||
}
|
||||
if p.Troubleshooter != "" {
|
||||
t.Errorf("Troubleshooter = %q, want empty when resp omits it", p.Troubleshooter)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,8 +261,6 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) {
|
||||
`"code": 99991679`,
|
||||
`"missing_scopes":`,
|
||||
`"docx:document"`,
|
||||
`"console_url":`,
|
||||
`open.feishu.cn/app/cli_a123/auth`,
|
||||
`"identity": "user"`,
|
||||
`"log_id": "lg-1"`,
|
||||
} {
|
||||
@@ -196,6 +273,12 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) {
|
||||
`"component"`,
|
||||
`"doc_url"`,
|
||||
`"retryable":`, // Retryable defaults false, omitempty → key absent
|
||||
// console_url is gated to SubtypeAppScopeNotApplied (bot-perspective
|
||||
// dev-action recovery). For user-perspective missing_scope the only
|
||||
// actionable recovery is `lark-cli auth login --scope ...` (already
|
||||
// in Hint), so the URL is dropped from the wire to avoid pointing an
|
||||
// end user at a console they cannot modify.
|
||||
`"console_url":`,
|
||||
} {
|
||||
if strings.Contains(out, mustNot) {
|
||||
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
|
||||
@@ -228,8 +311,8 @@ func TestRetryableEnvelope_TrueOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
resp := appScopeNotAppliedResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
@@ -240,8 +323,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"})
|
||||
resp := appScopeNotAppliedResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||
@@ -252,14 +335,36 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConsoleURL_EmptyAppID(t *testing.T) {
|
||||
resp := missingScopeResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"})
|
||||
resp := appScopeNotAppliedResp("docx:document")
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.ConsoleURL != "" {
|
||||
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", pe.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleURL_AttachedOnlyForAppScopeNotApplied pins the gating rule:
|
||||
// the developer-console deep-link only rides on the wire for
|
||||
// SubtypeAppScopeNotApplied (where the recovery is "developer applies the
|
||||
// scope"). User-perspective subtypes such as SubtypeMissingScope recover via
|
||||
// `lark-cli auth login --scope ...`, so the URL is dead weight on those
|
||||
// envelopes and is intentionally omitted to avoid pointing an end user at a
|
||||
// console they cannot modify.
|
||||
func TestConsoleURL_AttachedOnlyForAppScopeNotApplied(t *testing.T) {
|
||||
cc := errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"}
|
||||
|
||||
bot := errclass.BuildAPIError(appScopeNotAppliedResp("docx:document"), cc).(*errs.PermissionError)
|
||||
if bot.ConsoleURL == "" {
|
||||
t.Errorf("SubtypeAppScopeNotApplied envelope must carry ConsoleURL; got empty")
|
||||
}
|
||||
|
||||
user := errclass.BuildAPIError(missingScopeResp("docx:document"),
|
||||
errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"}).(*errs.PermissionError)
|
||||
if user.ConsoleURL != "" {
|
||||
t.Errorf("SubtypeMissingScope envelope must NOT carry ConsoleURL; got %q", user.ConsoleURL)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and
|
||||
// scope values so a hostile value cannot break out of the URL framing
|
||||
// (e.g. by smuggling extra `&` parameters or a `#` fragment).
|
||||
@@ -335,9 +440,10 @@ func TestPermissionError_DefaultIdentity(t *testing.T) {
|
||||
|
||||
func TestPermissionError_NoViolations(t *testing.T) {
|
||||
// permission error without a permission_violations array → MissingScopes nil,
|
||||
// ConsoleURL falls back to the no-scope form.
|
||||
resp := map[string]any{"code": 99991679, "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
||||
// ConsoleURL falls back to the no-scope form. Exercises the bot-perspective
|
||||
// SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides.
|
||||
resp := map[string]any{"code": 99991672, "msg": "x"}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
|
||||
pe := err.(*errs.PermissionError)
|
||||
if pe.MissingScopes != nil {
|
||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
||||
@@ -367,20 +473,24 @@ func TestExtractMissingScopes_Dedup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestServiceShortcutEnvelopeConverge guards that the wire envelope is
|
||||
// identical whether produced via the dispatcher (BuildAPIError — the normal
|
||||
// service / shortcut path) or constructed directly at the call site (the
|
||||
// cmd/service permission path).
|
||||
// TestServiceShortcutEnvelopeConverge guards that the wire envelope produced
|
||||
// by the dispatcher (BuildAPIError — the normal service / shortcut path)
|
||||
// converges with the envelope produced by the direct-construction path used
|
||||
// in cmd/service/service.go's checkServiceScopes pre-flight check.
|
||||
//
|
||||
// cmd/service/service.go's checkServiceScopes builds PermissionError using the
|
||||
// exported PermissionHint and ConsoleURL helpers — the same helpers
|
||||
// BuildAPIError uses. The hand-constructed branch below intentionally mirrors
|
||||
// service.go line-by-line so a future drift on either side (e.g. a new
|
||||
// extension field on PermissionError that only BuildAPIError populates) fails
|
||||
// loudly here. The remaining limitation is that this test invokes the helpers
|
||||
// directly rather than driving checkServiceScopes (which requires a credential
|
||||
// + factory mock). TODO: lift this into cmd/service_test.go once a lightweight
|
||||
// mock harness lands.
|
||||
// Both paths now share the same canonical helpers in internal/errclass for
|
||||
// Message (CanonicalPermissionMessage), Hint (PermissionHint), and
|
||||
// ConsoleURL (ConsoleURL); MissingScopes and Identity are filled identically.
|
||||
// A future drift on either side (e.g. a new extension field on
|
||||
// PermissionError that only BuildAPIError populates, or service.go inlining
|
||||
// its own message string again) fails this test loudly.
|
||||
//
|
||||
// One upstream-derived field is a documented exception: `code` (the Lark
|
||||
// API numeric code). The pre-flight check runs against a locally cached
|
||||
// scope list and has no upstream response to extract it from. The
|
||||
// comparison below strips that key from both envelopes so the assertion
|
||||
// isolates the contract fields that MUST converge: Subtype, Category,
|
||||
// Message, Hint, Identity, MissingScopes, ConsoleURL.
|
||||
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
||||
const (
|
||||
brand = "feishu"
|
||||
@@ -392,27 +502,21 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
||||
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
|
||||
resp := missingScopeResp(missing[0])
|
||||
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
|
||||
dispatcherPE, ok := dispatcherErr.(*errs.PermissionError)
|
||||
if !ok {
|
||||
if _, ok := dispatcherErr.(*errs.PermissionError); !ok {
|
||||
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
|
||||
}
|
||||
|
||||
// Path B: direct construction — exactly mirrors cmd/service/service.go's
|
||||
// checkServiceScopes (same helpers, same field-fill order). Code
|
||||
// and Message are copied from Path A so the byte-comparison below isolates
|
||||
// the contract under test (Hint + Identity + ConsoleURL convergence).
|
||||
directErr := &errs.PermissionError{
|
||||
Problem: errs.Problem{
|
||||
Category: errs.CategoryAuthorization,
|
||||
Subtype: errs.SubtypeMissingScope,
|
||||
Code: dispatcherPE.Code,
|
||||
Message: dispatcherPE.Message,
|
||||
Hint: errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope),
|
||||
},
|
||||
MissingScopes: missing,
|
||||
Identity: identity,
|
||||
ConsoleURL: errclass.ConsoleURL(brand, appID, missing),
|
||||
}
|
||||
// Path B: direct construction — exercises the same helpers that
|
||||
// cmd/service/service.go's newPreflightMissingScopeError uses. Keep this
|
||||
// in lock-step with that helper; if either drifts the byte-comparison
|
||||
// fails. ConsoleURL is intentionally NOT set on either path for
|
||||
// SubtypeMissingScope — see the gating rationale in buildPermissionError.
|
||||
consoleURL := errclass.ConsoleURL(brand, appID, missing)
|
||||
directErr := errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
|
||||
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
|
||||
WithMissingScopes(missing...).
|
||||
WithIdentity(identity)
|
||||
|
||||
var bufA, bufB bytes.Buffer
|
||||
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
|
||||
@@ -422,11 +526,34 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
||||
t.Fatal("direct path failed to emit typed envelope")
|
||||
}
|
||||
|
||||
if bufA.String() != bufB.String() {
|
||||
t.Errorf("dispatcher vs direct-construction envelopes diverge:\nDispatcher: %s\nDirect: %s", bufA.String(), bufB.String())
|
||||
// Strip `code` from both envelopes — see test doc above.
|
||||
stripA := stripUpstreamFields(t, bufA.Bytes())
|
||||
stripB := stripUpstreamFields(t, bufB.Bytes())
|
||||
if stripA != stripB {
|
||||
t.Errorf("dispatcher vs direct-construction envelopes diverge (upstream fields stripped):\nDispatcher: %s\nDirect: %s", stripA, stripB)
|
||||
}
|
||||
}
|
||||
|
||||
// stripUpstreamFields parses an envelope JSON and re-marshals it with the
|
||||
// upstream-derived "code" key removed from the inner "error" block. Used by
|
||||
// the convergence test to isolate contract fields shared between the
|
||||
// dispatcher and pre-flight paths.
|
||||
func stripUpstreamFields(t *testing.T, raw []byte) string {
|
||||
t.Helper()
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
t.Fatalf("envelope not valid JSON: %v\nraw: %s", err, raw)
|
||||
}
|
||||
if errBlock, ok := obj["error"].(map[string]any); ok {
|
||||
delete(errBlock, "code")
|
||||
}
|
||||
out, err := json.Marshal(obj)
|
||||
if err != nil {
|
||||
t.Fatalf("re-marshal failed: %v", err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
|
||||
// Mirrors what the cmd/service direct-construction path produces.
|
||||
pe := &errs.PermissionError{
|
||||
@@ -492,44 +619,48 @@ func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_UserWithScopes(t *testing.T) {
|
||||
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, "user", errs.SubtypeMissingScope)
|
||||
if !strings.Contains(got, "lark-cli auth login") {
|
||||
t.Errorf("user hint should suggest `lark-cli auth login`; got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
|
||||
t.Errorf("user hint should include missing scopes; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_BotWithScopes(t *testing.T) {
|
||||
got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope)
|
||||
if !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("bot hint should mention the open-platform console; got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "auth login") {
|
||||
t.Errorf("bot hint must not suggest re-running `auth login`; got %q", got)
|
||||
func TestBuildPermissionHint_MissingScopeRoutesToAuthLogin(t *testing.T) {
|
||||
// missing_scope means the user authorized the app but did not grant
|
||||
// this scope — recoverable by re-running `auth login`. Both user and
|
||||
// bot identities route the same way because the recovery action is
|
||||
// user-initiated either way.
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, identity, errs.SubtypeMissingScope, "")
|
||||
if !strings.Contains(got, "lark-cli auth login") {
|
||||
t.Errorf("identity=%q: hint should suggest `lark-cli auth login`; got %q", identity, got)
|
||||
}
|
||||
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
|
||||
t.Errorf("identity=%q: hint should include missing scopes; got %q", identity, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_NoScopes(t *testing.T) {
|
||||
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope); !strings.Contains(got, "required scopes") {
|
||||
t.Errorf("user no-scope hint missing fallback wording; got %q", got)
|
||||
// missing_scope with empty list — still suggests auth login even
|
||||
// without the explicit --scope argument.
|
||||
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope, ""); !strings.Contains(got, "lark-cli auth login") {
|
||||
t.Errorf("missing_scope no-scope hint should still suggest auth login; got %q", got)
|
||||
}
|
||||
if got := errclass.PermissionHint(nil, "bot", errs.SubtypeMissingScope); !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("bot no-scope hint should still point at the console; got %q", got)
|
||||
// app_scope_not_applied without console URL — still points at the
|
||||
// developer console (URL is optional context, not a routing axis).
|
||||
if got := errclass.PermissionHint(nil, "user", errs.SubtypeAppScopeNotApplied, ""); !strings.Contains(got, "developer console") {
|
||||
t.Errorf("app_scope_not_applied no-URL hint should still point at developer console; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
// 99991672 / app_scope_not_enabled means the scope has not been granted
|
||||
// 99991672 / app_scope_not_applied means the scope has not been granted
|
||||
// at the app level — re-authenticating cannot fix it. The hint must
|
||||
// point to the developer console regardless of caller identity, or
|
||||
// agents will loop on `auth login` forever.
|
||||
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact"
|
||||
for _, identity := range []string{"user", "bot", ""} {
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied)
|
||||
if !strings.Contains(got, "open platform console") {
|
||||
t.Errorf("identity=%q: hint should point to console; got %q", identity, got)
|
||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||
if !strings.Contains(got, "developer console") {
|
||||
t.Errorf("identity=%q: hint should point to developer console; got %q", identity, got)
|
||||
}
|
||||
if !strings.Contains(got, consoleURL) {
|
||||
t.Errorf("identity=%q: hint should embed the console URL; got %q", identity, got)
|
||||
}
|
||||
if strings.Contains(got, "auth login") {
|
||||
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
|
||||
@@ -537,6 +668,123 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildPermissionError_CanonicalMessage pins the per-subtype canonical
|
||||
// wording so the wire envelope's Message preserves Lark's official phrasing
|
||||
// ("access denied" / "unauthorized" / "token has no permission") and enhances
|
||||
// it with CLI context (app ID, scope list). Regressions here are user-visible.
|
||||
func TestBuildPermissionError_CanonicalMessage(t *testing.T) {
|
||||
const appID = "cli_xyz"
|
||||
cases := []struct {
|
||||
name string
|
||||
code int
|
||||
wantSubtype errs.Subtype
|
||||
// substrings the canonical message MUST contain
|
||||
wantSubstrs []string
|
||||
}{
|
||||
{
|
||||
name: "99991672 app_scope_not_applied",
|
||||
code: 99991672,
|
||||
wantSubtype: errs.SubtypeAppScopeNotApplied,
|
||||
wantSubstrs: []string{"access denied", "app " + appID, "contact:contact"},
|
||||
},
|
||||
{
|
||||
name: "99991679 missing_scope",
|
||||
code: 99991679,
|
||||
wantSubtype: errs.SubtypeMissingScope,
|
||||
wantSubstrs: []string{"unauthorized", "user authorization", "contact:contact"},
|
||||
},
|
||||
{
|
||||
name: "99991676 token_scope_insufficient",
|
||||
code: 99991676,
|
||||
wantSubtype: errs.SubtypeTokenScopeInsufficient,
|
||||
wantSubstrs: []string{"token has no permission"},
|
||||
},
|
||||
{
|
||||
name: "230027 user_unauthorized",
|
||||
code: 230027,
|
||||
wantSubtype: errs.SubtypeUserUnauthorized,
|
||||
wantSubstrs: []string{"access denied for this operation"},
|
||||
},
|
||||
{
|
||||
name: "99991673 app_unavailable",
|
||||
code: 99991673,
|
||||
wantSubtype: errs.SubtypeAppUnavailable,
|
||||
wantSubstrs: []string{"unauthorized app", "app " + appID, "not properly installed"},
|
||||
},
|
||||
{
|
||||
name: "99991662 app_disabled",
|
||||
code: 99991662,
|
||||
wantSubtype: errs.SubtypeAppDisabled,
|
||||
wantSubstrs: []string{"app " + appID, "not in use", "currently disabled"},
|
||||
},
|
||||
{
|
||||
name: "1470403 permission_denied",
|
||||
code: 1470403,
|
||||
wantSubtype: errs.SubtypePermissionDenied,
|
||||
wantSubstrs: []string{"user lacks permission"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resp := map[string]any{
|
||||
"code": tc.code,
|
||||
"msg": "upstream raw text — must be replaced",
|
||||
"error": map[string]any{"permission_violations": []any{map[string]any{"subject": "contact:contact"}}},
|
||||
}
|
||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: appID, Identity: "user"})
|
||||
pe, ok := err.(*errs.PermissionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *PermissionError, got %T", err)
|
||||
}
|
||||
if pe.Subtype != tc.wantSubtype {
|
||||
t.Errorf("Subtype = %q, want %q", pe.Subtype, tc.wantSubtype)
|
||||
}
|
||||
for _, sub := range tc.wantSubstrs {
|
||||
if !strings.Contains(pe.Message, sub) {
|
||||
t.Errorf("Message %q missing substring %q", pe.Message, sub)
|
||||
}
|
||||
}
|
||||
if pe.Message == "upstream raw text — must be replaced" {
|
||||
t.Errorf("Message must be rewritten to canonical text, got upstream verbatim: %q", pe.Message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalPermissionMessage_FallbackOnUnknownSubtype pins that an unknown
|
||||
// subtype (not in the per-subtype switch) preserves the upstream fallback
|
||||
// instead of producing an empty Message.
|
||||
func TestCanonicalPermissionMessage_FallbackOnUnknownSubtype(t *testing.T) {
|
||||
got := errclass.CanonicalPermissionMessage(errs.SubtypeUnknown, "cli_x", nil, "upstream verbatim")
|
||||
if got != "upstream verbatim" {
|
||||
t.Errorf("unknown subtype should preserve fallback; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCanonicalPermissionMessage_EmptyAppIDStillReadable pins the no-app-id
|
||||
// fallback wording so an early-init bootstrap path that produces a
|
||||
// PermissionError without ClassifyContext.AppID still emits useful text.
|
||||
func TestCanonicalPermissionMessage_EmptyAppIDStillReadable(t *testing.T) {
|
||||
cases := []struct {
|
||||
sub errs.Subtype
|
||||
substr string
|
||||
appIDIn string
|
||||
}{
|
||||
{errs.SubtypeAppScopeNotApplied, "app has not applied", ""},
|
||||
{errs.SubtypeAppUnavailable, "app is not properly installed", ""},
|
||||
{errs.SubtypeAppDisabled, "app is not in use", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := errclass.CanonicalPermissionMessage(tc.sub, tc.appIDIn, nil, "")
|
||||
if !strings.Contains(got, tc.substr) {
|
||||
t.Errorf("subtype=%s no-app-id message missing %q: got %q", tc.sub, tc.substr, got)
|
||||
}
|
||||
if strings.Contains(got, " app ") || strings.Contains(got, "app : ") {
|
||||
t.Errorf("subtype=%s no-app-id message has double space placeholder: %q", tc.sub, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) {
|
||||
// Regression: code 99991672 with user identity previously emitted
|
||||
// `lark-cli auth login --scope ...` which sends agents into a re-auth
|
||||
@@ -554,8 +802,8 @@ func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testin
|
||||
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "open platform console") {
|
||||
t.Errorf("Hint should route to console; got %q", p.Hint)
|
||||
if !strings.Contains(p.Hint, "developer console") {
|
||||
t.Errorf("Hint should route to developer console; got %q", p.Hint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "auth login") {
|
||||
t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint)
|
||||
|
||||
@@ -12,10 +12,16 @@ import (
|
||||
// CodeMeta is the classification metadata attached to a Lark numeric code.
|
||||
// It does NOT carry Message or Hint — those are derived at the dispatcher
|
||||
// (see BuildAPIError).
|
||||
//
|
||||
// Risk + Action are populated only for codes that route to CategoryConfirmation;
|
||||
// the dispatcher falls back to RiskUnknown + ctx.LarkCmd when either is empty
|
||||
// so the envelope is never wire-invalid.
|
||||
type CodeMeta struct {
|
||||
Category errs.Category
|
||||
Subtype errs.Subtype
|
||||
Retryable bool
|
||||
Risk string // CategoryConfirmation arm only; empty otherwise
|
||||
Action string // CategoryConfirmation arm only; empty otherwise
|
||||
}
|
||||
|
||||
// codeMeta is the central registry. Top-level entries (auth/authorization/api/
|
||||
@@ -27,42 +33,43 @@ type CodeMeta struct {
|
||||
// so sub-tables registering via init() can always assume codeMeta is non-nil.
|
||||
var codeMeta = map[int]CodeMeta{
|
||||
// CategoryAuthentication
|
||||
99991661: {errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, // Authorization header missing
|
||||
99991671: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // token format error (must start with t- / u-)
|
||||
99991668: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // UAT invalid/expired (server does not distinguish)
|
||||
99991663: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // access_token invalid
|
||||
99991677: {errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, // UAT expired
|
||||
20026: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, // refresh_token v1 legacy format
|
||||
20037: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, // refresh_token expired
|
||||
20064: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, // refresh_token revoked
|
||||
20073: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, // refresh_token already used
|
||||
20050: {errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, // refresh endpoint transient error
|
||||
99991661: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}, // Authorization header missing
|
||||
99991671: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // token format error (must start with t- / u-)
|
||||
99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish)
|
||||
99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid
|
||||
99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired
|
||||
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format
|
||||
20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired
|
||||
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
|
||||
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
|
||||
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error
|
||||
|
||||
// CategoryAuthorization
|
||||
99991672: {errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, false},
|
||||
99991676: {errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, false},
|
||||
99991679: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false}, // user authorized app but did not grant this scope
|
||||
230027: {errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false}, // user never authorized the app
|
||||
99991673: {errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false}, // app status unavailable
|
||||
99991662: {errs.CategoryAuthorization, errs.SubtypeAppNotInstalled, false}, // app not enabled / not installed in tenant
|
||||
99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied},
|
||||
99991676: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeTokenScopeInsufficient},
|
||||
99991679: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}, // user authorized app but did not grant this scope
|
||||
230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app
|
||||
99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable
|
||||
99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled in tenant
|
||||
|
||||
// CategoryAPI
|
||||
99991400: {errs.CategoryAPI, errs.SubtypeRateLimit, true},
|
||||
1061045: {errs.CategoryAPI, errs.SubtypeConflict, true},
|
||||
131009: {errs.CategoryAPI, errs.SubtypeConflict, true}, // wiki write-path lock contention; retryable with backoff
|
||||
1064510: {errs.CategoryAPI, errs.SubtypeCrossTenant, false},
|
||||
1064511: {errs.CategoryAPI, errs.SubtypeCrossBrand, false},
|
||||
1310246: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
1063006: {errs.CategoryAPI, errs.SubtypeRateLimit, false}, // drive perm-apply quota; 5/day, not short-term retryable
|
||||
1063007: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
231205: {errs.CategoryAPI, errs.SubtypeOwnershipMismatch, false},
|
||||
99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true},
|
||||
1061045: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true},
|
||||
131009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // wiki write-path lock contention; retryable with backoff
|
||||
1064510: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossTenant},
|
||||
1064511: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossBrand},
|
||||
1310246: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
|
||||
1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable
|
||||
1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
|
||||
231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch},
|
||||
|
||||
// CategoryConfig
|
||||
99991543: {errs.CategoryConfig, errs.SubtypeInvalidClient, false}, // RFC 6749 §5.2 — app_id / app_secret incorrect
|
||||
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
|
||||
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543)
|
||||
|
||||
// CategoryPolicy
|
||||
21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false},
|
||||
21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false},
|
||||
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||
21001: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied},
|
||||
}
|
||||
|
||||
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
|
||||
|
||||
17
internal/errclass/codemeta_drive.go
Normal file
17
internal/errclass/codemeta_drive.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// driveCodeMeta holds drive/docs-service Lark code → CodeMeta mappings.
|
||||
// Only codes whose meaning is verifiable from repo evidence are registered;
|
||||
// ambiguous codes fall back to CategoryAPI via BuildAPIError.
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var driveCodeMeta = map[int]CodeMeta{
|
||||
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
|
||||
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(driveCodeMeta, "drive") }
|
||||
43
internal/errclass/codemeta_drive_test.go
Normal file
43
internal/errclass/codemeta_drive_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// TestLookupCodeMeta_DriveCodes pins each drive-service code registered via the
|
||||
// codemeta_drive.go init() merge to its expected Category/Subtype/Retryable.
|
||||
// Each case traces to repo evidence (see codemeta_drive.go comments).
|
||||
func TestLookupCodeMeta_DriveCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
// 1061044: upload with a nonexistent parent folder token. The drive E2E
|
||||
// (tests_e2e/drive/2026_06_01_errs_migrate_drive_test.go) drives this
|
||||
// producer via a nonexistent parent folder → referenced resource missing.
|
||||
{1061044, errs.CategoryAPI, errs.SubtypeNotFound, false},
|
||||
// 1069302: comment endpoint's opaque "Invalid or missing parameters"
|
||||
// (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection.
|
||||
{1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,20 +5,21 @@ package errclass
|
||||
|
||||
import "github.com/larksuite/cli/errs"
|
||||
|
||||
// taskCodeMeta holds the task-service-specific Lark code classifications.
|
||||
// 1470403 permission_denied is CategoryAuthorization (exit 3); the other task
|
||||
// codes route to CategoryAPI / CategoryValidation. BuildAPIError consumes this
|
||||
// map via mergeCodeMeta + LookupCodeMeta.
|
||||
// taskCodeMeta holds task-service Lark code → CodeMeta mappings.
|
||||
// All Subtypes are framework-shared (errs.SubtypeXxx) — task does not declare
|
||||
// service-specific Subtypes because none of these codes carry semantics beyond
|
||||
// the cross-service taxonomy (NotFound / QuotaExceeded / etc.).
|
||||
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||
var taskCodeMeta = map[int]CodeMeta{
|
||||
1470400: {errs.CategoryValidation, errs.SubtypeTaskInvalidParams, false},
|
||||
1470403: {errs.CategoryAuthorization, errs.SubtypeTaskPermissionDenied, false}, // permission_denied
|
||||
1470404: {errs.CategoryAPI, errs.SubtypeTaskNotFound, false},
|
||||
1470422: {errs.CategoryAPI, errs.SubtypeTaskConflict, true},
|
||||
1470500: {errs.CategoryAPI, errs.SubtypeTaskServerError, true},
|
||||
1470610: {errs.CategoryAPI, errs.SubtypeTaskAssigneeLimit, false},
|
||||
1470611: {errs.CategoryAPI, errs.SubtypeTaskFollowerLimit, false},
|
||||
1470612: {errs.CategoryAPI, errs.SubtypeTaskTasklistMemberLimit, false},
|
||||
1470613: {errs.CategoryAPI, errs.SubtypeTaskReminderExists, false},
|
||||
1470400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid_params
|
||||
1470403: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // permission_denied (resource-level)
|
||||
1470404: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // not_found
|
||||
1470422: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // conflict (retryable)
|
||||
1470500: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // server_error (retryable)
|
||||
1470610: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // assignee_limit
|
||||
1470611: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // follower_limit
|
||||
1470612: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tasklist_member_limit
|
||||
1470613: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // reminder_exists
|
||||
}
|
||||
|
||||
func init() { mergeCodeMeta(taskCodeMeta, "task") }
|
||||
|
||||
@@ -4,12 +4,45 @@
|
||||
package errclass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestLookupCodeMeta_CredentialCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
wantCat errs.Category
|
||||
wantSubtype errs.Subtype
|
||||
wantRetry bool
|
||||
}{
|
||||
{99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, false},
|
||||
{99991671, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
|
||||
{99991668, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
|
||||
{99991663, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
|
||||
{99991677, errs.CategoryAuthentication, errs.SubtypeTokenExpired, false},
|
||||
{20026, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
|
||||
{20037, errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false},
|
||||
{20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false},
|
||||
{20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false},
|
||||
{20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(tc.code)
|
||||
if !ok {
|
||||
t.Fatalf("code %d not registered in codeMeta", tc.code)
|
||||
}
|
||||
if meta.Category != tc.wantCat || meta.Subtype != tc.wantSubtype || meta.Retryable != tc.wantRetry {
|
||||
t.Errorf("code %d: got %+v, want Category=%v Subtype=%v Retryable=%v",
|
||||
tc.code, meta, tc.wantCat, tc.wantSubtype, tc.wantRetry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_MissingScope(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(99991679)
|
||||
if !ok {
|
||||
@@ -29,8 +62,8 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
||||
if got.Category != errs.CategoryAuthorization {
|
||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||
}
|
||||
if got.Subtype != errs.Subtype("task_permission_denied") {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "task_permission_denied")
|
||||
if got.Subtype != errs.SubtypePermissionDenied {
|
||||
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypePermissionDenied)
|
||||
}
|
||||
if got.Retryable {
|
||||
t.Errorf("Retryable = true, want false")
|
||||
@@ -70,6 +103,27 @@ func TestLookupCodeMeta_Unknown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupCodeMeta_ConfigCode_99991543 pins the Lark "app_id or app_secret
|
||||
// is incorrect" code to CategoryConfig / SubtypeInvalidClient. The CLI cannot
|
||||
// retry around a wrong app credential — the operator has to edit the local
|
||||
// config — so this MUST stay non-retryable and live in the config category
|
||||
// (not the API category it was originally classed under).
|
||||
func TestLookupCodeMeta_ConfigCode_99991543(t *testing.T) {
|
||||
meta, ok := LookupCodeMeta(99991543)
|
||||
if !ok {
|
||||
t.Fatal("99991543 not registered in codeMeta")
|
||||
}
|
||||
if meta.Category != errs.CategoryConfig {
|
||||
t.Errorf("category = %v, want %v", meta.Category, errs.CategoryConfig)
|
||||
}
|
||||
if meta.Subtype != errs.SubtypeInvalidClient {
|
||||
t.Errorf("subtype = %v, want %v", meta.Subtype, errs.SubtypeInvalidClient)
|
||||
}
|
||||
if meta.Retryable {
|
||||
t.Errorf("Retryable = true, want false (wrong app credential is operator-fix)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
|
||||
got, ok := LookupCodeMeta(21000)
|
||||
if !ok {
|
||||
@@ -93,7 +147,7 @@ func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("panic value is not a string: %T (%v)", r, r)
|
||||
}
|
||||
for _, needle := range []string{"1470403", "task_permission_denied", "intruder", "test"} {
|
||||
for _, needle := range []string{"1470403", "permission_denied", "intruder", "test"} {
|
||||
if !strings.Contains(msg, needle) {
|
||||
t.Errorf("panic message %q missing substring %q", msg, needle)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package errcompat bridges the legacy *core.ConfigError shape into the
|
||||
// canonical typed errors taxonomy in errs/. It is a thin boundary helper —
|
||||
// placed in its own package so it can import both core (for the legacy
|
||||
// type) and errs (for the typed targets) without creating an import cycle
|
||||
// with internal/errclass, which intentionally avoids depending on
|
||||
// internal/core.
|
||||
// Package errcompat provides boundary helpers that bridge legacy error types
|
||||
// to the typed errs/ taxonomy. These helpers run at the dispatcher boundary
|
||||
// (cmd/root.go.handleRootError) before the typed envelope writer, converting
|
||||
// pre-typed-taxonomy errors (*core.ConfigError, *internalauth.NeedAuthorizationError)
|
||||
// into typed *errs.* errors while preserving the original error in the Cause
|
||||
// chain so existing `errors.As` callers continue to match.
|
||||
package errcompat
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
)
|
||||
|
||||
// PromoteConfigError is the stage-2 boundary helper that will convert a
|
||||
// *core.ConfigError into the matching typed errs.* error. In stage 1 it
|
||||
// is a passthrough — the dispatcher continues to render *core.ConfigError
|
||||
// via the legacy envelope path (cmd/root.go asExitError) so the wire
|
||||
// shape stays identical to pre-PR. Per-domain typed migration in stage 2+
|
||||
// will fill in the actual promotion logic alongside its corresponding
|
||||
// wire-change announcement.
|
||||
// PromoteConfigError converts a legacy *core.ConfigError into the matching
|
||||
// typed errs.*Error based on cfgErr.Type. Called from cmd/root.go.handleRootError
|
||||
// before the typed envelope writer. The original *core.ConfigError is preserved
|
||||
// in the Cause chain so external `errors.As(&core.ConfigError{})` callers
|
||||
// (cmd/auth/list.go, cmd/doctor/doctor.go, etc.) still match.
|
||||
func PromoteConfigError(cfgErr *core.ConfigError) error {
|
||||
if cfgErr == nil {
|
||||
return nil
|
||||
}
|
||||
return cfgErr
|
||||
switch cfgErr.Type {
|
||||
case "auth":
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", cfgErr.Message).
|
||||
WithHint("%s", cfgErr.Hint).
|
||||
WithCause(cfgErr)
|
||||
case "config":
|
||||
subtype := errs.SubtypeNotConfigured
|
||||
lower := strings.ToLower(cfgErr.Message)
|
||||
if strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") {
|
||||
subtype = errs.SubtypeInvalidConfig
|
||||
}
|
||||
return errs.NewConfigError(subtype, "%s", cfgErr.Message).
|
||||
WithHint("%s", cfgErr.Hint).
|
||||
WithCause(cfgErr)
|
||||
default:
|
||||
// dynamic Type (e.g. workspace name like "bind"/"hermes"/"openclaw") → NotConfigured
|
||||
return errs.NewConfigError(errs.SubtypeNotConfigured, "%s", cfgErr.Message).
|
||||
WithHint("%s", cfgErr.Hint).
|
||||
WithCause(cfgErr)
|
||||
}
|
||||
}
|
||||
|
||||
// _ keeps the errs import live so stage-2 fill-in does not need to re-add it.
|
||||
var _ = errs.CategoryConfig
|
||||
|
||||
32
internal/errcompat/promote_auth.go
Normal file
32
internal/errcompat/promote_auth.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errcompat
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
)
|
||||
|
||||
// PromoteAuthError converts a legacy *internalauth.NeedAuthorizationError into
|
||||
// *errs.AuthenticationError{Subtype: TokenMissing}. The Message field MUST
|
||||
// contain "need_user_authorization" so the marker invariant guardrail in
|
||||
// cmd/root_test.go and internal/auth/errors_test.go still holds.
|
||||
//
|
||||
// Hint mirrors newTokenMissingError in internal/client/client.go so both
|
||||
// token-missing surfaces converge on the same recovery vocabulary. cmd's
|
||||
// applyNeedAuthorizationHint appends per-command scopes onto this Hint with
|
||||
// a "\n" join, so the action prompt is preserved even when scopes are added.
|
||||
//
|
||||
// Called from cmd/root.go.handleRootError when errors.As matches
|
||||
// *NeedAuthorizationError, before WriteTypedErrorEnvelope.
|
||||
func PromoteAuthError(err *internalauth.NeedAuthorizationError) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||
"need_user_authorization (user: %s)", err.UserOpenId).
|
||||
WithUserOpenID(err.UserOpenId).
|
||||
WithHint("run: lark-cli auth login to re-authorize").
|
||||
WithCause(err)
|
||||
}
|
||||
79
internal/errcompat/promote_auth_test.go
Normal file
79
internal/errcompat/promote_auth_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package errcompat
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
internalauth "github.com/larksuite/cli/internal/auth"
|
||||
)
|
||||
|
||||
func TestPromoteAuthError_PromotesNeedAuthorizationError(t *testing.T) {
|
||||
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
||||
got := PromoteAuthError(needAuth)
|
||||
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(got, &authErr) {
|
||||
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
|
||||
}
|
||||
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||
t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||
}
|
||||
|
||||
// Cause chain must preserve original *NeedAuthorizationError so legacy
|
||||
// consumers (auth.IsNeedUserAuthorizationError + errors.As pattern in
|
||||
// internal/auth/errors.go:42) still match.
|
||||
var preserved *internalauth.NeedAuthorizationError
|
||||
if !errors.As(got, &preserved) {
|
||||
t.Error("Unwrap chain lost *NeedAuthorizationError — breaks auth.IsNeedUserAuthorizationError consumer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteAuthError_PreservesNeedUserAuthorizationMarker(t *testing.T) {
|
||||
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
||||
got := PromoteAuthError(needAuth)
|
||||
if !strings.Contains(got.Error(), "need_user_authorization") {
|
||||
t.Errorf("Message must contain need_user_authorization marker, got: %q", got.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteAuthError_PreservesUserOpenID(t *testing.T) {
|
||||
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_test_open_id"}
|
||||
got := PromoteAuthError(needAuth)
|
||||
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(got, &authErr) {
|
||||
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
|
||||
}
|
||||
if authErr.UserOpenID != "u_test_open_id" {
|
||||
t.Errorf("UserOpenID = %q, want preserved", authErr.UserOpenID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPromoteAuthError_CarriesAuthLoginHint pins that the recovery action
|
||||
// prompt is attached at promotion time — without this Hint, downstream
|
||||
// consumers see authentication/token_missing but no "run: lark-cli auth login"
|
||||
// guidance, mirroring the pre-typed UX failure when NeedAuthorizationError
|
||||
// surfaced as a bare network error. cmd's applyNeedAuthorizationHint relies
|
||||
// on this Hint being non-empty so scope enrichment appends instead of
|
||||
// overwrites the recovery prompt.
|
||||
func TestPromoteAuthError_CarriesAuthLoginHint(t *testing.T) {
|
||||
got := PromoteAuthError(&internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"})
|
||||
var authErr *errs.AuthenticationError
|
||||
if !errors.As(got, &authErr) {
|
||||
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
|
||||
}
|
||||
if !strings.Contains(authErr.Hint, "lark-cli auth login") {
|
||||
t.Errorf("Hint must guide user to re-authorize, got: %q", authErr.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteAuthError_Nil_ReturnsNil(t *testing.T) {
|
||||
if got := PromoteAuthError(nil); got != nil {
|
||||
t.Errorf("nil input should return nil, got %v", got)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user