mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
16 Commits
v1.0.44
...
feat/sidec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52dc09af95 | ||
|
|
07da0c8090 | ||
|
|
0aa9e96d18 | ||
|
|
e57d97f341 | ||
|
|
57ba4fae61 | ||
|
|
925ae5ecd6 | ||
|
|
4710a294f5 | ||
|
|
bc8e9bd6ef | ||
|
|
f65712cacf | ||
|
|
915cc623cc | ||
|
|
3bfb80951d | ||
|
|
639259fbfd | ||
|
|
0bdd7de807 | ||
|
|
99e314fe0b | ||
|
|
50b3f0a2af | ||
|
|
b1ecf2d0f9 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -82,6 +82,8 @@ jobs:
|
|||||||
run: python3 scripts/fetch_meta.py
|
run: python3 scripts/fetch_meta.py
|
||||||
- name: Run golangci-lint
|
- 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
|
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:
|
coverage:
|
||||||
needs: fast-gate
|
needs: fast-gate
|
||||||
|
|||||||
@@ -49,18 +49,26 @@ linters:
|
|||||||
- gocritic
|
- gocritic
|
||||||
- depguard
|
- depguard
|
||||||
- forbidigo
|
- 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:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
- path: internal/vfs/
|
- path: internal/vfs/
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- forbidigo
|
||||||
# The shortcuts-no-raw-http forbidigo rule below is shortcuts-only;
|
# shortcuts-no-raw-http is shortcuts-only; internal/ wraps raw HTTP
|
||||||
# internal/ legitimately wraps raw HTTP for the client / credential layer.
|
# for the client / credential layer.
|
||||||
- path-except: shortcuts/
|
- path-except: shortcuts/
|
||||||
text: shortcuts-no-raw-http
|
text: shortcuts-no-raw-http
|
||||||
linters:
|
linters:
|
||||||
- forbidigo
|
- 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)
|
||||||
|
text: errs-typed-only
|
||||||
|
linters:
|
||||||
|
- forbidigo
|
||||||
|
|
||||||
settings:
|
settings:
|
||||||
depguard:
|
depguard:
|
||||||
@@ -79,6 +87,13 @@ linters:
|
|||||||
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
Use runtime.FileIO() for file operations or runtime.ValidatePath() for path validation.
|
||||||
forbidigo:
|
forbidigo:
|
||||||
forbid:
|
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).
|
||||||
# ── http: shortcuts must not construct raw HTTP requests ──
|
# ── http: shortcuts must not construct raw HTTP requests ──
|
||||||
# Bans request / client construction; constants (http.MethodPost,
|
# Bans request / client construction; constants (http.MethodPost,
|
||||||
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
# http.StatusOK) and pure helpers (http.StatusText, http.Header) are
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [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
|
## [v1.0.44] - 2026-05-29
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -948,6 +964,7 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
|
[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.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.43]: https://github.com/larksuite/cli/releases/tag/v1.0.43
|
||||||
[v1.0.42]: https://github.com/larksuite/cli/releases/tag/v1.0.42
|
[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)
|
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// MarkRaw tells the dispatcher to skip enrichPermissionError so the
|
// MarkRaw tells the dispatcher to skip the legacy enrichPermissionError
|
||||||
// raw API error detail (log_id, troubleshooter, permission_violations)
|
// pass on *output.ExitError values. Typed *errs.* errors that flow
|
||||||
// stays on the wire — `lark-cli api` callers explicitly want the raw
|
// through here keep their canonical message / hint from BuildAPIError;
|
||||||
// envelope.
|
// MarkRaw is a no-op on those (it only flips a flag on *ExitError).
|
||||||
return output.MarkRaw(err)
|
return output.MarkRaw(err)
|
||||||
}
|
}
|
||||||
err = client.HandleResponse(resp, client.ResponseOptions{
|
err = client.HandleResponse(resp, client.ResponseOptions{
|
||||||
@@ -253,14 +253,14 @@ func apiRun(opts *APIOptions) error {
|
|||||||
FileIO: f.ResolveFileIO(opts.Ctx),
|
FileIO: f.ResolveFileIO(opts.Ctx),
|
||||||
CommandPath: opts.Cmd.CommandPath(),
|
CommandPath: opts.Cmd.CommandPath(),
|
||||||
Identity: opts.As,
|
Identity: opts.As,
|
||||||
// Stage 1: CheckResponse emits the legacy *output.ExitError envelope.
|
// CheckResponse routes through errclass.BuildAPIError for known Lark
|
||||||
// Per-domain migration in stage 2+ will route through
|
// codes (typed PermissionError / AuthenticationError / ...). For
|
||||||
// errclass.BuildAPIError to populate identity-aware fields
|
// unknown codes it falls back to *errs.APIError. The Brand+AppID on
|
||||||
// (PermissionError.ConsoleURL needs Brand+AppID from the client).
|
// the client populate identity-aware fields (ConsoleURL etc.).
|
||||||
CheckError: ac.CheckResponse,
|
CheckError: ac.CheckResponse,
|
||||||
})
|
})
|
||||||
// MarkRaw: see comment above on the DoAPI path. Applies equally to
|
// MarkRaw: see comment above on the DoAPI path. Skips legacy
|
||||||
// HandleResponse failures so the raw API error survives to the wire.
|
// *ExitError enrichment; typed errors flow through unchanged.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.MarkRaw(err)
|
return output.MarkRaw(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"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)
|
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"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewCmdAuth creates the auth command with subcommands.
|
// 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
|
var resp userInfoResponse
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
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 {
|
if resp.Code != 0 {
|
||||||
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
|
return "", "", fmt.Errorf("failed to get user info [%d]: %s", resp.Code, resp.Msg)
|
||||||
@@ -110,6 +111,11 @@ type appInfoResponse struct {
|
|||||||
} `json:"data"`
|
} `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.
|
// getAppInfo queries app info from the Lark API.
|
||||||
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo, error) {
|
||||||
ac, err := f.NewAPIClient()
|
ac, err := f.NewAPIClient()
|
||||||
@@ -131,10 +137,10 @@ func getAppInfo(ctx context.Context, f *cmdutil.Factory, appId string) (*appInfo
|
|||||||
|
|
||||||
var resp appInfoResponse
|
var resp appInfoResponse
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
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 {
|
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
|
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
|
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"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"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 {
|
type authScopesTokenResolver struct {
|
||||||
requests []credential.TokenSpec
|
requests []credential.TokenSpec
|
||||||
}
|
}
|
||||||
@@ -389,15 +438,8 @@ func TestAuthBlockedByExternalProvider(t *testing.T) {
|
|||||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -47,7 +48,7 @@ func authCheckRun(opts *CheckOptions) error {
|
|||||||
|
|
||||||
required := strings.Fields(opts.Scope)
|
required := strings.Fields(opts.Scope)
|
||||||
if len(required) == 0 {
|
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()
|
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/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"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).`,
|
to generate QR codes (supports ASCII and PNG formats).`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
if mode := f.ResolveStrictMode(cmd.Context()); mode == core.StrictModeBot {
|
||||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
fmt.Sprintf("strict mode is %q, user login is disabled in this profile", mode),
|
"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)")
|
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()
|
opts.Ctx = cmd.Context()
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
@@ -158,14 +160,14 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
for _, d := range selectedDomains {
|
for _, d := range selectedDomains {
|
||||||
if !knownDomains[d] {
|
if !knownDomains[d] {
|
||||||
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
|
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))
|
available := make([]string, 0, len(knownDomains))
|
||||||
for k := range knownDomains {
|
for k := range knownDomains {
|
||||||
available = append(available, k)
|
available = append(available, k)
|
||||||
}
|
}
|
||||||
sort.Strings(available)
|
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
|
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||||
|
|
||||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
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 {
|
if !hasAnyOption {
|
||||||
@@ -183,7 +185,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return output.ErrValidation("no login options selected")
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "no login options selected")
|
||||||
}
|
}
|
||||||
selectedDomains = result.Domains
|
selectedDomains = result.Domains
|
||||||
scopeLevel = result.ScopeLevel
|
scopeLevel = result.ScopeLevel
|
||||||
@@ -199,7 +201,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
log(msg.HintFooter)
|
log(msg.HintFooter)
|
||||||
log("")
|
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.")
|
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 == "" {
|
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.
|
// Merge --scope additively with the resolved domain scopes.
|
||||||
@@ -248,13 +250,13 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
if len(opts.Exclude) > 0 {
|
if len(opts.Exclude) > 0 {
|
||||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||||
if len(unknown) > 0 {
|
if len(unknown) > 0 {
|
||||||
return output.ErrValidation(
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
"these --exclude scopes are not present in the requested set: %s",
|
"these --exclude scopes are not present in the requested set: %s",
|
||||||
strings.Join(unknown, ", "))
|
strings.Join(unknown, ", ")).WithParam("--exclude")
|
||||||
}
|
}
|
||||||
finalScope = excluded
|
finalScope = excluded
|
||||||
if strings.TrimSpace(finalScope) == "" {
|
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)
|
authResp, err := larkauth.RequestDeviceAuthorization(httpClient, config.AppID, config.AppSecret, config.Brand, finalScope, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
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
|
// --no-wait: return immediately with device code and URL
|
||||||
@@ -277,12 +279,18 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
"verification_url": authResp.VerificationUriComplete,
|
"verification_url": authResp.VerificationUriComplete,
|
||||||
"device_code": authResp.DeviceCode,
|
"device_code": authResp.DeviceCode,
|
||||||
"expires_in": authResp.ExpiresIn,
|
"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 := json.NewEncoder(f.IOStreams.Out)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
if err := encoder.Encode(data); err != nil {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -304,7 +312,7 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
encoder := json.NewEncoder(f.IOStreams.Out)
|
encoder := json.NewEncoder(f.IOStreams.Out)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
if err := encoder.Encode(data); err != nil {
|
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 {
|
} else {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
fmt.Fprintf(f.IOStreams.ErrOut, msg.OpenURL)
|
||||||
@@ -325,25 +333,25 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
"event": "authorization_failed",
|
"event": "authorization_failed",
|
||||||
"error": result.Message,
|
"error": result.Message,
|
||||||
}); err != nil {
|
}); 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.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 {
|
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
|
// Step 6: Get user info
|
||||||
log(msg.AuthSuccess)
|
log(msg.AuthSuccess)
|
||||||
sdk, err := f.LarkClient()
|
sdk, err := f.LarkClient()
|
||||||
if err != nil {
|
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)
|
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||||
if err != nil {
|
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)
|
scopeSummary := loadLoginScopeSummary(config.AppID, openId, finalScope, result.Token.Scope)
|
||||||
@@ -361,13 +369,13 @@ func authLoginRun(opts *LoginOptions) error {
|
|||||||
GrantedAt: now,
|
GrantedAt: now,
|
||||||
}
|
}
|
||||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
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
|
// Step 8: Update config — overwrite Users to single user, clean old tokens
|
||||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
_ = 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 {
|
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) {
|
if shouldRemoveLoginRequestedScope(result) {
|
||||||
cleanupRequestedScope()
|
cleanupRequestedScope()
|
||||||
}
|
}
|
||||||
return output.ErrAuth("authorization failed: %s", result.Message)
|
return errs.NewAuthenticationError(errs.SubtypeUnknown, "authorization failed: %s", result.Message)
|
||||||
}
|
}
|
||||||
defer cleanupRequestedScope()
|
defer cleanupRequestedScope()
|
||||||
if result.Token == nil {
|
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
|
// Get user info
|
||||||
log(msg.AuthSuccess)
|
log(msg.AuthSuccess)
|
||||||
sdk, err := f.LarkClient()
|
sdk, err := f.LarkClient()
|
||||||
if err != nil {
|
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)
|
openId, userName, err := getUserInfo(opts.Ctx, sdk, result.Token.AccessToken)
|
||||||
if err != nil {
|
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)
|
scopeSummary := loadLoginScopeSummary(config.AppID, openId, requestedScope, result.Token.Scope)
|
||||||
@@ -443,13 +451,13 @@ func authLoginPollDeviceCode(opts *LoginOptions, config *core.CliConfig, msg *lo
|
|||||||
GrantedAt: now,
|
GrantedAt: now,
|
||||||
}
|
}
|
||||||
if err := larkauth.SetStoredToken(storedToken); err != nil {
|
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
|
// Update config — overwrite Users to single user, clean old tokens
|
||||||
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
if err := syncLoginUserToProfile(config.ProfileName, config.AppID, openId, userName); err != nil {
|
||||||
_ = larkauth.RemoveStoredToken(config.AppID, openId)
|
_ = 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 {
|
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 {
|
func syncLoginUserToProfile(profileName, appID, openID, userName string) error {
|
||||||
multi, err := core.LoadMultiAppConfig()
|
multi, err := core.LoadMultiAppConfig()
|
||||||
if err != nil {
|
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)
|
app := findProfileByName(multi, profileName)
|
||||||
if app == nil {
|
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...)
|
oldUsers := append([]core.AppUser(nil), app.Users...)
|
||||||
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
app.Users = []core.AppUser{{UserOpenId: openID, UserName: userName}}
|
||||||
if err := core.SaveMultiAppConfig(multi); err != nil {
|
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 {
|
for _, oldUser := range oldUsers {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -162,7 +163,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, bra
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(selectedDomains) == 0 {
|
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
|
// Compute scope summary
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"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))
|
fmt.Fprintln(f.IOStreams.Out, string(b))
|
||||||
return output.ErrBare(output.ExitAuth)
|
return output.ErrBare(output.ExitAuth)
|
||||||
}
|
}
|
||||||
detail := map[string]interface{}{
|
return errs.NewPermissionError(errs.SubtypeMissingScope, "%s", issue.Message).
|
||||||
"requested": issue.Summary.Requested,
|
WithHint("%s", issue.Hint).
|
||||||
"granted": issue.Summary.Granted,
|
WithIdentity("user").
|
||||||
"missing": issue.Summary.Missing,
|
WithRequestedScopes(issue.Summary.Requested...).
|
||||||
}
|
WithGrantedScopes(issue.Summary.Granted...).
|
||||||
// Legacy *output.ExitError producer: this literal predates the typed
|
WithMissingScopes(issue.Summary.Missing...)
|
||||||
// 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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(f.IOStreams.ErrOut)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -400,12 +400,11 @@ func TestHandleLoginScopeIssue_NonJSONAlignsWithLoginSuccess(t *testing.T) {
|
|||||||
Granted: []string{"base:app:copy"},
|
Granted: []string{"base:app:copy"},
|
||||||
},
|
},
|
||||||
}, "ou_user", "tester")
|
}, "ou_user", "tester")
|
||||||
var exitErr *output.ExitError
|
if err == nil {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatal("expected error, got nil")
|
||||||
t.Fatalf("expected ExitError, got %v", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAuth {
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
}
|
}
|
||||||
got := stderr.String()
|
got := stderr.String()
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
@@ -443,12 +442,11 @@ func TestHandleLoginScopeIssue_JSONAlignsWithLoginSuccess(t *testing.T) {
|
|||||||
Granted: []string{"base:app:copy"},
|
Granted: []string{"base:app:copy"},
|
||||||
},
|
},
|
||||||
}, "ou_user", "tester")
|
}, "ou_user", "tester")
|
||||||
var exitErr *output.ExitError
|
if err == nil {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatal("expected error, got nil")
|
||||||
t.Fatalf("expected ExitError, got %v", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAuth {
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
}
|
}
|
||||||
|
|
||||||
var data map[string]interface{}
|
var data map[string]interface{}
|
||||||
@@ -653,12 +651,11 @@ func TestAuthLoginRun_MissingRequestedScopeAlignsWithLoginSuccess(t *testing.T)
|
|||||||
Ctx: context.Background(),
|
Ctx: context.Background(),
|
||||||
Scope: "im:message:send",
|
Scope: "im:message:send",
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
if err == nil {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatal("expected error, got nil")
|
||||||
t.Fatalf("expected ExitError, got %v", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAuth {
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAuth)
|
t.Fatalf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
}
|
}
|
||||||
got := stderr.String()
|
got := stderr.String()
|
||||||
for _, want := range []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) {
|
func TestAuthLoginRun_JSONWriteFailure_NoWaitReturnsWriterError(t *testing.T) {
|
||||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
ProfileName: "default",
|
ProfileName: "default",
|
||||||
@@ -961,8 +1042,11 @@ func TestAuthLoginRun_NoWaitJSONHintIncludesRawURLGuidance(t *testing.T) {
|
|||||||
"final message of the turn",
|
"final message of the turn",
|
||||||
"return control to the user",
|
"return control to the user",
|
||||||
"do not block on --device-code in the same turn",
|
"do not block on --device-code in the same turn",
|
||||||
"After the user confirms authorization in a later step",
|
"come back and notify",
|
||||||
"lark-cli auth login --device-code device-code",
|
"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) {
|
if !strings.Contains(hint, want) {
|
||||||
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
t.Fatalf("hint missing %q, got:\n%s", want, hint)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -60,7 +61,7 @@ func authLogoutRun(opts *LogoutOptions) error {
|
|||||||
}
|
}
|
||||||
app.Users = []core.AppUser{}
|
app.Users = []core.AppUser{}
|
||||||
if err := core.SaveMultiAppConfig(multi); err != 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)
|
||||||
}
|
}
|
||||||
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
output.PrintSuccess(f.IOStreams.ErrOut, "Logged out")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
"github.com/skip2/go-qrcode"
|
"github.com/skip2/go-qrcode"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
"github.com/larksuite/cli/internal/validate"
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"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.
|
// runQRCode executes the auth qrcode command.
|
||||||
func runQRCode(opts *QRCodeOptions) error {
|
func runQRCode(opts *QRCodeOptions) error {
|
||||||
if opts.URL == "" {
|
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 {
|
if opts.ASCII {
|
||||||
@@ -75,20 +75,20 @@ func runQRCode(opts *QRCodeOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.Output == "" {
|
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 {
|
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 {
|
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)
|
safePath, err := validate.SafeOutputPath(opts.Output)
|
||||||
if err != nil {
|
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 {
|
if err := generateImageQRCode(opts.URL, opts.Size, safePath); err != nil {
|
||||||
@@ -108,7 +108,7 @@ func runQRCode(opts *QRCodeOptions) error {
|
|||||||
encoder := json.NewEncoder(out)
|
encoder := json.NewEncoder(out)
|
||||||
encoder.SetEscapeHTML(false)
|
encoder.SetEscapeHTML(false)
|
||||||
if err := encoder.Encode(result); err != nil {
|
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
|
return nil
|
||||||
@@ -118,12 +118,12 @@ func runQRCode(opts *QRCodeOptions) error {
|
|||||||
func generateImageQRCode(url string, size int, outputPath string) error {
|
func generateImageQRCode(url string, size int, outputPath string) error {
|
||||||
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
png, err := qrcode.Encode(url, qrcode.Medium, size)
|
||||||
if err != nil {
|
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)
|
err = vfs.WriteFile(outputPath, png, 0644)
|
||||||
if err != nil {
|
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
|
return nil
|
||||||
@@ -133,7 +133,7 @@ func generateImageQRCode(url string, size int, outputPath string) error {
|
|||||||
func generateASCIIQRCode(url string, w io.Writer) error {
|
func generateASCIIQRCode(url string, w io.Writer) error {
|
||||||
q, err := qrcode.New(url, qrcode.Medium)
|
q, err := qrcode.New(url, qrcode.Medium)
|
||||||
if err != nil {
|
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))
|
fmt.Fprint(w, q.ToSmallString(false))
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package auth
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -171,29 +170,15 @@ func TestNewCmdAuthQRCode_HelpText(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunQRCode_MissingURL(t *testing.T) {
|
func TestRunQRCode_MissingURL(t *testing.T) {
|
||||||
err := runQRCode(&QRCodeOptions{URL: ""})
|
err := runQRCode(&QRCodeOptions{URL: ""})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRunQRCode_MissingOutput(t *testing.T) {
|
func TestRunQRCode_MissingOutput(t *testing.T) {
|
||||||
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
err := runQRCode(&QRCodeOptions{URL: "https://example.com", Size: 256})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,15 +188,8 @@ func TestRunQRCode_InvalidSize(t *testing.T) {
|
|||||||
Size: 16,
|
Size: 16,
|
||||||
Output: "qr.png",
|
Output: "qr.png",
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,15 +199,8 @@ func TestRunQRCode_SizeTooLarge(t *testing.T) {
|
|||||||
Size: 2048,
|
Size: 2048,
|
||||||
Output: "qr.png",
|
Output: "qr.png",
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,12 +210,8 @@ func TestRunQRCode_UnsafeOutputPath(t *testing.T) {
|
|||||||
Size: 256,
|
Size: 256,
|
||||||
Output: "/etc/passwd",
|
Output: "/etc/passwd",
|
||||||
})
|
})
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,15 +296,8 @@ func TestGenerateImageQRCode_WriteError(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error writing to nonexistent directory")
|
t.Fatal("expected error writing to nonexistent directory")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitInternal {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitInternal)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,11 +318,7 @@ func TestGenerateASCIIQRCode_EmptyString(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for empty string")
|
t.Fatal("expected error for empty string")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if err == nil {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatal("expected error, got nil")
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
@@ -50,11 +51,23 @@ func authScopesRun(opts *ScopesOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "Querying app scopes...\n\n")
|
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 {
|
if err != nil {
|
||||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
// Discriminate by error type so transport / parse failures are not
|
||||||
fmt.Sprintf("failed to get app scope info: %v", err),
|
// reclassified as PermissionError(MissingScope) — re-auth does not
|
||||||
"ensure the app has enabled the application:application:self_manage scope.")
|
// 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" {
|
if opts.Format == "pretty" {
|
||||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
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/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/i18n"
|
"github.com/larksuite/cli/internal/i18n"
|
||||||
@@ -181,7 +182,7 @@ type existingBinding struct {
|
|||||||
func finalizeSource(opts *BindOptions) (string, error) {
|
func finalizeSource(opts *BindOptions) (string, error) {
|
||||||
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
explicit := strings.TrimSpace(strings.ToLower(opts.Source))
|
||||||
if explicit != "" && explicit != "openclaw" && explicit != "hermes" && explicit != "lark-channel" {
|
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
|
var detected string
|
||||||
@@ -198,9 +199,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
|||||||
// before any interactive prompts — running inside Hermes with
|
// before any interactive prompts — running inside Hermes with
|
||||||
// --source openclaw (or vice versa) is almost always a mistake.
|
// --source openclaw (or vice versa) is almost always a mistake.
|
||||||
if explicit != "" && detected != "" && explicit != detected {
|
if explicit != "" && detected != "" && explicit != detected {
|
||||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
fmt.Sprintf("--source %q does not match detected Agent environment (%s)", explicit, detected),
|
"--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")
|
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
|
// TUI: prompt for language before any downstream prompts. The source
|
||||||
@@ -228,9 +230,10 @@ func finalizeSource(opts *BindOptions) (string, error) {
|
|||||||
if opts.IsTUI {
|
if opts.IsTUI {
|
||||||
return tuiSelectSource(opts)
|
return tuiSelectSource(opts)
|
||||||
}
|
}
|
||||||
return "", output.ErrWithHint(output.ExitValidation, "bind",
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
"cannot determine Agent source: no --source flag and no Agent environment detected",
|
"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")
|
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
|
// reconcileExistingBinding reads any existing config at configPath and decides
|
||||||
@@ -335,8 +338,9 @@ func warnIdentityEscalation(opts *BindOptions, previousConfigBytes []byte) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
msg := getBindMsg(opts.UILang)
|
msg := getBindMsg(opts.UILang)
|
||||||
return output.ErrWithHint(output.ExitValidation, "bind",
|
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite,
|
||||||
msg.IdentityEscalationMessage, msg.IdentityEscalationHint)
|
"config bind --force", "%s", msg.IdentityEscalationMessage).
|
||||||
|
WithHint("%s", msg.IdentityEscalationHint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// noticeUserDefaultRisk surfaces the user-identity impersonation risk on every
|
// 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}}
|
multi := &core.MultiAppConfig{Apps: []core.AppConfig{*appConfig}}
|
||||||
|
|
||||||
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "bind",
|
return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err)
|
||||||
"failed to create workspace directory: %v", err)
|
|
||||||
}
|
}
|
||||||
data, err := json.MarshalIndent(multi, "", " ")
|
data, err := json.MarshalIndent(multi, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "bind",
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err)
|
||||||
"failed to marshal config: %v", err)
|
|
||||||
}
|
}
|
||||||
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "bind",
|
return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err)
|
||||||
"failed to write config %s: %v", configPath, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
replaced := previousConfigBytes != nil
|
replaced := previousConfigBytes != nil
|
||||||
@@ -628,7 +629,7 @@ func validateBindFlags(opts *BindOptions) error {
|
|||||||
switch opts.Identity {
|
switch opts.Identity {
|
||||||
case "bot-only", "user-default":
|
case "bot-only", "user-default":
|
||||||
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)
|
lang, err := cmdutil.ParseLangFlag(opts.Lang)
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ import (
|
|||||||
|
|
||||||
// assertExitError checks the full structured error in one assertion. It
|
// assertExitError checks the full structured error in one assertion. It
|
||||||
// accepts both *output.ExitError (used by output.ErrWithHint) and the
|
// 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) {
|
func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.ErrDetail) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -52,7 +54,18 @@ func assertExitError(t *testing.T, err error, wantCode int, wantDetail output.Er
|
|||||||
}
|
}
|
||||||
return
|
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
|
// 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
|
// TestFactory has IsTerminal=false by default
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
err := configBindRun(&BindOptions{Factory: f, Source: ""})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "bind",
|
Type: "validation",
|
||||||
Message: "cannot determine Agent source: no --source flag and no Agent environment detected",
|
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",
|
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)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "bind",
|
Type: "validation",
|
||||||
Message: `--source "openclaw" does not match detected Agent environment (hermes)`,
|
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",
|
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)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "bind",
|
Type: "validation",
|
||||||
Message: `--source "hermes" does not match detected Agent environment (openclaw)`,
|
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",
|
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)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "hermes",
|
Type: "config",
|
||||||
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
Message: "failed to read Hermes config: open " + envPath + ": no such file or directory",
|
||||||
Hint: "verify Hermes is installed and configured at " + envPath,
|
Hint: "verify Hermes is installed and configured at " + envPath,
|
||||||
})
|
})
|
||||||
@@ -571,8 +584,8 @@ func TestConfigBindRun_OpenClawMissingFile(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
configPath := filepath.Join(openclawHome, ".openclaw", "openclaw.json")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||||
Hint: "verify OpenClaw is installed and configured",
|
Hint: "verify OpenClaw is installed and configured",
|
||||||
})
|
})
|
||||||
@@ -719,7 +732,7 @@ func TestConfigBindRun_SourceEnvMismatch_LarkChannelFlagInOpenClawEnv(t *testing
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "bind",
|
Type: "validation",
|
||||||
Message: `--source "lark-channel" does not match detected Agent environment (openclaw)`,
|
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",
|
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)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
configPath := filepath.Join(fakeHome, ".lark-channel", "config.json")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "lark-channel",
|
Type: "config",
|
||||||
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
Message: "cannot read " + configPath + ": open " + configPath + ": no such file or directory",
|
||||||
Hint: "verify lark-channel-bridge is installed and configured",
|
Hint: "verify lark-channel-bridge is installed and configured",
|
||||||
})
|
})
|
||||||
@@ -757,8 +770,8 @@ func TestConfigBindRun_LarkChannelEmptyAppID(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "lark-channel",
|
Type: "config",
|
||||||
Message: "accounts.app.id missing in " + configPath,
|
Message: "accounts.app.id missing in " + configPath,
|
||||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
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)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "lark-channel"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "lark-channel",
|
Type: "config",
|
||||||
Message: "accounts.app.secret is empty in " + configPath,
|
Message: "accounts.app.secret is empty in " + configPath,
|
||||||
Hint: "run lark-channel-bridge's setup to populate the app credential",
|
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 {
|
if err == nil {
|
||||||
t.Fatal("expected error for multi-account without --app-id, got nil")
|
t.Fatal("expected error for multi-account without --app-id, got nil")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1179,7 +1188,7 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
|||||||
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
// each accepted variant so every ErrDetail field (Type, Code, Message,
|
||||||
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
// Hint, ConsoleURL, Detail, and any future addition) is still compared.
|
||||||
base := output.ErrDetail{
|
base := output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||||
}
|
}
|
||||||
wantWorkFirst := base
|
wantWorkFirst := base
|
||||||
@@ -1187,20 +1196,17 @@ func TestConfigBindRun_OpenClawMultiAccount_TTYFlagMode(t *testing.T) {
|
|||||||
wantPersonalFirst := base
|
wantPersonalFirst := base
|
||||||
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
wantPersonalFirst.Hint = "available app IDs:\n cli_personal_222 (personal)\n cli_work_111 (work)"
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
t.Fatalf("error type = %T, want *output.ExitError; err = %v", err, err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitValidation {
|
var ve *errs.ValidationError
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("error type = %T, want *errs.ValidationError; err = %v", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil {
|
got := output.ErrDetail{Type: string(ve.Category), Message: ve.Message, Hint: ve.Hint}
|
||||||
t.Fatal("expected non-nil error detail")
|
if !reflect.DeepEqual(got, wantWorkFirst) && !reflect.DeepEqual(got, wantPersonalFirst) {
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(*exitErr.Detail, wantWorkFirst) &&
|
|
||||||
!reflect.DeepEqual(*exitErr.Detail, wantPersonalFirst) {
|
|
||||||
t.Errorf("error detail did not match any accepted variant:\n got: %+v\n want: %+v OR %+v",
|
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)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", AppID: "nonexistent"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_only_one",
|
Hint: "available app IDs:\n cli_only_one",
|
||||||
})
|
})
|
||||||
@@ -1357,11 +1363,19 @@ func TestConfigBindRun_WarnsOnIdentityEscalationWithoutForce(t *testing.T) {
|
|||||||
Identity: "user-default",
|
Identity: "user-default",
|
||||||
})
|
})
|
||||||
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
msg := getBindMsg("zh") // flag mode leaves Lang empty → zh default
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
var ce *errs.ConfirmationRequiredError
|
||||||
Type: "bind",
|
if !errors.As(err, &ce) {
|
||||||
Message: msg.IdentityEscalationMessage,
|
t.Fatalf("error type = %T, want *errs.ConfirmationRequiredError; error = %v", err, err)
|
||||||
Hint: msg.IdentityEscalationHint,
|
}
|
||||||
})
|
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
|
// Config on disk must remain untouched — the gate runs before
|
||||||
// commitBinding writes anything.
|
// commitBinding writes anything.
|
||||||
@@ -1522,8 +1536,8 @@ func TestConfigBindRun_HermesMissingAppID(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "hermes",
|
Type: "config",
|
||||||
Message: "FEISHU_APP_ID not found in " + envPath,
|
Message: "FEISHU_APP_ID not found in " + envPath,
|
||||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||||
})
|
})
|
||||||
@@ -1542,8 +1556,8 @@ func TestConfigBindRun_HermesMissingAppSecret(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "hermes"})
|
||||||
envPath := filepath.Join(hermesHome, ".env")
|
envPath := filepath.Join(hermesHome, ".env")
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "hermes",
|
Type: "config",
|
||||||
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
Message: "FEISHU_APP_SECRET not found in " + envPath,
|
||||||
Hint: "run 'hermes setup' to configure Feishu credentials",
|
Hint: "run 'hermes setup' to configure Feishu credentials",
|
||||||
})
|
})
|
||||||
@@ -1568,8 +1582,8 @@ func TestConfigBindRun_OpenClawMissingFeishu(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "openclaw.json missing channels.feishu section",
|
Message: "openclaw.json missing channels.feishu section",
|
||||||
Hint: "configure Feishu in OpenClaw first",
|
Hint: "configure Feishu in OpenClaw first",
|
||||||
})
|
})
|
||||||
@@ -1596,8 +1610,8 @@ func TestConfigBindRun_OpenClawEmptyAppSecret(t *testing.T) {
|
|||||||
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
openclawPath := filepath.Join(openclawDir, "openclaw.json")
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
Message: "appSecret is empty for app cli_no_secret in " + openclawPath,
|
||||||
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
Hint: "configure channels.feishu.appSecret in openclaw.json",
|
||||||
})
|
})
|
||||||
@@ -1658,8 +1672,8 @@ func TestConfigBindRun_OpenClawDisabledAccount(t *testing.T) {
|
|||||||
|
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
err := configBindRun(&BindOptions{Factory: f, Source: "openclaw"})
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "no Feishu app configured in openclaw.json",
|
Message: "no Feishu app configured in openclaw.json",
|
||||||
Hint: "configure channels.feishu.appId in openclaw.json",
|
Hint: "configure channels.feishu.appId in openclaw.json",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/binding"
|
"github.com/larksuite/cli/internal/binding"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/larksuite/cli/internal/vfs"
|
"github.com/larksuite/cli/internal/vfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ func newBinder(source string, opts *BindOptions) (SourceBinder, error) {
|
|||||||
case "lark-channel":
|
case "lark-channel":
|
||||||
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
return &larkChannelBinder{opts: opts, path: resolveLarkChannelConfigPath()}, nil
|
||||||
default:
|
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.
|
// from ListCandidates itself and never reach here.
|
||||||
switch src {
|
switch src {
|
||||||
case "openclaw":
|
case "openclaw":
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "no Feishu app configured in openclaw.json").
|
||||||
"no Feishu app configured in openclaw.json",
|
WithHint("configure channels.feishu.appId in openclaw.json")
|
||||||
"configure channels.feishu.appId in openclaw.json")
|
|
||||||
default:
|
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 &candidates[i], nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id %q not found in %s", appIDFlag, cfgBase).
|
||||||
fmt.Sprintf("--app-id %q not found in %s", appIDFlag, cfgBase),
|
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
||||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
WithParam("--app-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(candidates) == 1 {
|
if len(candidates) == 1 {
|
||||||
@@ -112,9 +111,9 @@ func selectCandidate(
|
|||||||
return tuiPrompt(candidates)
|
return tuiPrompt(candidates)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, src,
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "multiple accounts in %s; pass --app-id <id>", cfgBase).
|
||||||
fmt.Sprintf("multiple accounts in %s; pass --app-id <id>", cfgBase),
|
WithHint("available app IDs:\n %s", formatCandidates(candidates)).
|
||||||
fmt.Sprintf("available app IDs:\n %s", formatCandidates(candidates)))
|
WithParam("--app-id")
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatCandidates renders candidates as "AppID (Label)" lines for error hints.
|
// 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) {
|
func (b *openclawBinder) ListCandidates() ([]Candidate, error) {
|
||||||
cfg, err := binding.ReadOpenClawConfig(b.path)
|
cfg, err := binding.ReadOpenClawConfig(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
WithHint("verify OpenClaw is installed and configured").
|
||||||
"verify OpenClaw is installed and configured")
|
WithCause(err)
|
||||||
}
|
}
|
||||||
if cfg.Channels.Feishu == nil {
|
if cfg.Channels.Feishu == nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "openclaw.json missing channels.feishu section").
|
||||||
"openclaw.json missing channels.feishu section",
|
WithHint("configure Feishu in OpenClaw first")
|
||||||
"configure Feishu in OpenClaw first")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
raw := binding.ListCandidateApps(cfg.Channels.Feishu)
|
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) {
|
func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.cfg == nil {
|
if b.cfg == nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||||
"internal: Build called before ListCandidates")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var selected *binding.CandidateApp
|
var selected *binding.CandidateApp
|
||||||
@@ -184,26 +181,25 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if selected == nil {
|
if selected == nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID)
|
||||||
"internal: appID %q not in candidates", appID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if selected.AppSecret.IsZero() {
|
if selected.AppSecret.IsZero() {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path).
|
||||||
fmt.Sprintf("appSecret is empty for app %s in %s", selected.AppID, b.path),
|
WithHint("configure channels.feishu.appSecret in openclaw.json")
|
||||||
"configure channels.feishu.appSecret in openclaw.json")
|
|
||||||
}
|
}
|
||||||
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "openclaw",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err).
|
||||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", selected.AppID, err),
|
WithHint("check appSecret configuration in %s", b.path).
|
||||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "openclaw",
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
WithHint("use file: reference in config to bypass keychain").
|
||||||
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
@@ -229,15 +225,14 @@ func (b *hermesBinder) ConfigPath() string { return b.path }
|
|||||||
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
func (b *hermesBinder) ListCandidates() ([]Candidate, error) {
|
||||||
envMap, err := readDotenv(b.path)
|
envMap, err := readDotenv(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "failed to read Hermes config: %v", err).
|
||||||
fmt.Sprintf("failed to read Hermes config: %v", err),
|
WithHint("verify Hermes is installed and configured at %s", b.path).
|
||||||
fmt.Sprintf("verify Hermes is installed and configured at %s", b.path))
|
WithCause(err)
|
||||||
}
|
}
|
||||||
appID := envMap["FEISHU_APP_ID"]
|
appID := envMap["FEISHU_APP_ID"]
|
||||||
if appID == "" {
|
if appID == "" {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "FEISHU_APP_ID not found in %s", b.path).
|
||||||
fmt.Sprintf("FEISHU_APP_ID not found in %s", b.path),
|
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||||
"run 'hermes setup' to configure Feishu credentials")
|
|
||||||
}
|
}
|
||||||
b.envMap = envMap
|
b.envMap = envMap
|
||||||
return []Candidate{{AppID: appID, Label: "default"}}, nil
|
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) {
|
func (b *hermesBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.envMap == nil {
|
if b.envMap == nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||||
"internal: Build called before ListCandidates")
|
|
||||||
}
|
}
|
||||||
if b.envMap["FEISHU_APP_ID"] != appID {
|
if b.envMap["FEISHU_APP_ID"] != appID {
|
||||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match env", appID)
|
||||||
"internal: appID %q does not match env", appID)
|
|
||||||
}
|
}
|
||||||
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
appSecret := b.envMap["FEISHU_APP_SECRET"]
|
||||||
if appSecret == "" {
|
if appSecret == "" {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "hermes",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "FEISHU_APP_SECRET not found in %s", b.path).
|
||||||
fmt.Sprintf("FEISHU_APP_SECRET not found in %s", b.path),
|
WithHint("run 'hermes setup' to configure Feishu credentials")
|
||||||
"run 'hermes setup' to configure Feishu credentials")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
stored, err := core.ForStorage(appID, core.PlainSecret(appSecret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "hermes",
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||||
"keychain unavailable: %v\nhint: use file: reference in config to bypass keychain", err)
|
WithHint("use file: reference in config to bypass keychain").
|
||||||
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
@@ -290,14 +283,13 @@ func (b *larkChannelBinder) ConfigPath() string { return b.path }
|
|||||||
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
func (b *larkChannelBinder) ListCandidates() ([]Candidate, error) {
|
||||||
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
cfg, err := binding.ReadLarkChannelConfig(b.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "cannot read %s: %v", b.path, err).
|
||||||
fmt.Sprintf("cannot read %s: %v", b.path, err),
|
WithHint("verify lark-channel-bridge is installed and configured").
|
||||||
"verify lark-channel-bridge is installed and configured")
|
WithCause(err)
|
||||||
}
|
}
|
||||||
if cfg.Accounts.App.ID == "" {
|
if cfg.Accounts.App.ID == "" {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
return nil, errs.NewConfigError(errs.SubtypeNotConfigured, "accounts.app.id missing in %s", b.path).
|
||||||
fmt.Sprintf("accounts.app.id missing in %s", b.path),
|
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
||||||
"run lark-channel-bridge's setup to populate the app credential")
|
|
||||||
}
|
}
|
||||||
b.cfg = cfg
|
b.cfg = cfg
|
||||||
return []Candidate{{AppID: cfg.Accounts.App.ID, Label: "default"}}, nil
|
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) {
|
func (b *larkChannelBinder) Build(appID string) (*core.AppConfig, error) {
|
||||||
if b.cfg == nil {
|
if b.cfg == nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates")
|
||||||
"internal: Build called before ListCandidates")
|
|
||||||
}
|
}
|
||||||
if b.cfg.Accounts.App.ID != appID {
|
if b.cfg.Accounts.App.ID != appID {
|
||||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q does not match config", appID)
|
||||||
"internal: appID %q does not match config", appID)
|
|
||||||
}
|
}
|
||||||
if b.cfg.Accounts.App.Secret.IsZero() {
|
if b.cfg.Accounts.App.Secret.IsZero() {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "accounts.app.secret is empty in %s", b.path).
|
||||||
fmt.Sprintf("accounts.app.secret is empty in %s", b.path),
|
WithHint("run lark-channel-bridge's setup to populate the app credential")
|
||||||
"run lark-channel-bridge's setup to populate the app credential")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve through the same SecretInput pipeline openclaw uses, so
|
// Resolve through the same SecretInput pipeline openclaw uses, so
|
||||||
// bridge configs can use ${VAR} / env / file / exec just like openclaw.
|
// 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)
|
secret, err := binding.ResolveSecretInput(b.cfg.Accounts.App.Secret, b.cfg.Secrets, os.Getenv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.ErrWithHint(output.ExitValidation, "lark-channel",
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", appID, err).
|
||||||
fmt.Sprintf("failed to resolve appSecret for %s: %v", appID, err),
|
WithHint("check appSecret configuration in %s", b.path).
|
||||||
fmt.Sprintf("check appSecret configuration in %s", b.path))
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
stored, err := core.ForStorage(appID, core.PlainSecret(secret), b.opts.Factory.Keychain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, output.Errorf(output.ExitInternal, "lark-channel",
|
return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err).
|
||||||
"keychain unavailable: %v", err)
|
WithHint("use file: reference in config to bypass keychain").
|
||||||
|
WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &core.AppConfig{
|
return &core.AppConfig{
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func assertCandidate(t *testing.T, got *Candidate, want Candidate) {
|
|||||||
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
func TestSelectCandidate_ZeroCandidates_OpenClaw(t *testing.T) {
|
||||||
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
b := &fakeBinder{name: "openclaw", path: "/tmp/openclaw.json"}
|
||||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "config",
|
||||||
Message: "no Feishu app configured in openclaw.json",
|
Message: "no Feishu app configured in openclaw.json",
|
||||||
Hint: "configure channels.feishu.appId 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.
|
// even before it has a bespoke error message.
|
||||||
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
b := &fakeBinder{name: "hermes", path: "/tmp/.env"}
|
||||||
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, nil, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitAuth, output.ErrDetail{
|
||||||
Type: "validation",
|
Type: "config",
|
||||||
Message: "hermes: no app configured",
|
Message: "hermes: no app configured",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ func TestSelectCandidate_AppIDFlag_NoMatch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
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))
|
_, err := selectCandidate(b, candidates, "", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
Message: "multiple accounts in openclaw.json; pass --app-id <id>",
|
||||||
Hint: "available app IDs:\n cli_work (work)\n cli_home (home)",
|
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"}}
|
candidates := []Candidate{{AppID: "cli_only"}}
|
||||||
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
_, err := selectCandidate(b, candidates, "nonexistent", false, tuiUnreachable(t))
|
||||||
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
assertExitError(t, err, output.ExitValidation, output.ErrDetail{
|
||||||
Type: "openclaw",
|
Type: "validation",
|
||||||
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
Message: `--app-id "nonexistent" not found in openclaw.json`,
|
||||||
Hint: "available app IDs:\n cli_only",
|
Hint: "available app IDs:\n cli_only",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -126,15 +126,11 @@ func TestConfigShowRun_NoActiveProfileReturnsStructuredError(t *testing.T) {
|
|||||||
t.Fatal("expected error")
|
t.Fatal("expected error")
|
||||||
}
|
}
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitAuth {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitAuth)
|
||||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitValidation {
|
if !strings.Contains(err.Error(), "no active profile") {
|
||||||
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
t.Fatalf("error = %v, want to contain 'no active profile'", err)
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,15 +465,8 @@ func TestConfigBlockedByExternalProvider(t *testing.T) {
|
|||||||
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
if matched != nil && matched != cmd && !matched.SilenceUsage {
|
||||||
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
t.Error("expected PersistentPreRunE to set SilenceUsage on matched subcommand")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
if gotCode := output.ExitCodeOf(err); gotCode != output.ExitValidation {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Errorf("exit code = %d, want %d", gotCode, output.ExitValidation)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -41,12 +41,12 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {
|
|||||||
|
|
||||||
value := args[0]
|
value := args[0]
|
||||||
if value != "user" && value != "bot" && value != "auto" {
|
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)
|
app.DefaultAs = core.Identity(value)
|
||||||
if err := core.SaveMultiAppConfig(multi); err != 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.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
fmt.Fprintf(f.IOStreams.ErrOut, "Default identity set to: %s\n", value)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -245,9 +246,29 @@ func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
|||||||
return -1
|
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 {
|
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
||||||
if existing == nil {
|
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
|
var app *core.AppConfig
|
||||||
@@ -255,17 +276,20 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
|||||||
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
||||||
app = &existing.Apps[idx]
|
app = &existing.Apps[idx]
|
||||||
} else {
|
} 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 {
|
} else {
|
||||||
app = existing.CurrentAppConfig("")
|
app = existing.CurrentAppConfig("")
|
||||||
if app == nil {
|
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 {
|
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
|
app.AppId = appID
|
||||||
@@ -282,13 +306,13 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
scanner := bufio.NewScanner(f.IOStreams.In)
|
scanner := bufio.NewScanner(f.IOStreams.In)
|
||||||
if !scanner.Scan() {
|
if !scanner.Scan() {
|
||||||
if err := scanner.Err(); err != nil {
|
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())
|
opts.appSecret = strings.TrimSpace(scanner.Text())
|
||||||
if opts.appSecret == "" {
|
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
|
// Validate --profile name if set
|
||||||
if opts.ProfileName != "" {
|
if opts.ProfileName != "" {
|
||||||
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
||||||
return output.ErrValidation("%v", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithCause(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,10 +333,10 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
brand := parseBrand(opts.Brand)
|
brand := parseBrand(opts.Brand)
|
||||||
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
||||||
if err != nil {
|
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 {
|
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()))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||||
printLangPreferenceConfirmation(opts)
|
printLangPreferenceConfirmation(opts)
|
||||||
@@ -344,15 +368,15 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
if result == nil {
|
||||||
return output.ErrValidation("app creation returned no result")
|
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
||||||
}
|
}
|
||||||
existing, _ := core.LoadMultiAppConfig()
|
existing, _ := core.LoadMultiAppConfig()
|
||||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||||
if err != nil {
|
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 {
|
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)
|
printLangPreferenceConfirmation(opts)
|
||||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
||||||
@@ -366,7 +390,8 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if result == nil {
|
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()
|
existing, _ := core.LoadMultiAppConfig()
|
||||||
@@ -375,23 +400,19 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
// New secret provided (either from "create" or "existing" with input)
|
// New secret provided (either from "create" or "existing" with input)
|
||||||
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
||||||
if err != nil {
|
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 {
|
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 != "" {
|
} else if result.Mode == "existing" && result.AppID != "" {
|
||||||
// Existing app with unchanged secret — update app ID and brand only
|
// 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 {
|
if err := wrapUpdateExistingProfileErr(updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang)); err != nil {
|
||||||
// Deprecated: legacy *output.ExitError passthrough; removed after typed migration.
|
return err
|
||||||
var exitErr *output.ExitError
|
|
||||||
if errors.As(err, &exitErr) {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
|
||||||
}
|
}
|
||||||
} else {
|
} 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" {
|
if result.Mode == "existing" {
|
||||||
@@ -403,7 +424,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
|
|
||||||
// Non-terminal: cannot run interactive mode, guide user to --new
|
// Non-terminal: cannot run interactive mode, guide user to --new
|
||||||
if !f.IOStreams.IsTerminal {
|
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)
|
// Mode 5: Legacy interactive (readline fallback)
|
||||||
@@ -431,7 +452,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
appIdInput, err := readLine(prompt)
|
appIdInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = "App Secret"
|
prompt = "App Secret"
|
||||||
@@ -440,7 +461,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
appSecretInput, err := readLine(prompt)
|
appSecretInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = "Brand (lark/feishu)"
|
prompt = "Brand (lark/feishu)"
|
||||||
@@ -451,7 +472,7 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
brandInput, err := readLine(prompt)
|
brandInput, err := readLine(prompt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrValidation("%s", err)
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithCause(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resolvedAppId := appIdInput
|
resolvedAppId := appIdInput
|
||||||
@@ -473,15 +494,16 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if resolvedAppId == "" || resolvedSecret.IsZero() {
|
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)
|
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
||||||
if err != nil {
|
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 {
|
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()))
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
||||||
printLangPreferenceConfirmation(opts)
|
printLangPreferenceConfirmation(opts)
|
||||||
|
|||||||
@@ -6,16 +6,17 @@ package config
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
qrcode "github.com/skip2/go-qrcode"
|
qrcode "github.com/skip2/go-qrcode"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
larkauth "github.com/larksuite/cli/internal/auth"
|
larkauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/transport"
|
||||||
)
|
)
|
||||||
|
|
||||||
// configInitResult holds the result of the interactive config init flow.
|
// configInitResult holds the result of the interactive config init flow.
|
||||||
@@ -125,8 +126,16 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if appID == "" || appSecret == "" {
|
switch {
|
||||||
return nil, output.ErrValidation("App ID and App Secret cannot be empty")
|
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{
|
return &configInitResult{
|
||||||
@@ -168,10 +177,12 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Request app registration (begin)
|
// 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)
|
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
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
|
// 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)
|
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
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
|
// 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)
|
// 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)
|
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
||||||
if err != nil {
|
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 == "" {
|
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
|
// Determine final brand from response
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -53,12 +54,10 @@ func configKeychainDowngradeRun(f *cmdutil.Factory) error {
|
|||||||
|
|
||||||
result, err := keychain.DowngradeMasterKeyToFile(service)
|
result, err := keychain.DowngradeMasterKeyToFile(service)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.ErrWithHint(
|
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||||
output.ExitAPI,
|
"keychain downgrade failed: %v", err).
|
||||||
"config",
|
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.").
|
||||||
fmt.Sprintf("keychain downgrade failed: %v", err),
|
WithCause(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.",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/spf13/cobra"
|
"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)",
|
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.`,
|
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 {
|
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
|
return cmd
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ func runConfigPluginsShow(f *cmdutil.Factory) error {
|
|||||||
"version": p.Version,
|
"version": p.Version,
|
||||||
"capabilities": p.Capabilities,
|
"capabilities": p.Capabilities,
|
||||||
}
|
}
|
||||||
if p.Rule != nil {
|
if len(p.Rules) > 0 {
|
||||||
entry["rule"] = p.Rule
|
entry["rules"] = p.Rules
|
||||||
}
|
}
|
||||||
entry["hooks"] = map[string]any{
|
entry["hooks"] = map[string]any{
|
||||||
"observers": p.Observers,
|
"observers": p.Observers,
|
||||||
|
|||||||
@@ -59,16 +59,20 @@ func runConfigPolicyShow(f *cmdutil.Factory) error {
|
|||||||
"source_name": sourceName,
|
"source_name": sourceName,
|
||||||
"denied_paths": active.DeniedPaths,
|
"denied_paths": active.DeniedPaths,
|
||||||
}
|
}
|
||||||
if active.Rule != nil {
|
if len(active.Rules) > 0 {
|
||||||
out["rule"] = map[string]any{
|
rules := make([]map[string]any, 0, len(active.Rules))
|
||||||
"name": active.Rule.Name,
|
for _, r := range active.Rules {
|
||||||
"description": active.Rule.Description,
|
rules = append(rules, map[string]any{
|
||||||
"allow": active.Rule.Allow,
|
"name": r.Name,
|
||||||
"deny": active.Rule.Deny,
|
"description": r.Description,
|
||||||
"max_risk": active.Rule.MaxRisk,
|
"allow": r.Allow,
|
||||||
"identities": active.Rule.Identities,
|
"deny": r.Deny,
|
||||||
"allow_unannotated": active.Rule.AllowUnannotated,
|
"max_risk": r.MaxRisk,
|
||||||
|
"identities": r.Identities,
|
||||||
|
"allow_unannotated": r.AllowUnannotated,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
out["rules"] = rules
|
||||||
}
|
}
|
||||||
output.PrintJson(f.IOStreams.Out, out)
|
output.PrintJson(f.IOStreams.Out, out)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
|||||||
MaxRisk: "read",
|
MaxRisk: "read",
|
||||||
}
|
}
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rule: rule,
|
Rules: []*platform.Rule{rule},
|
||||||
Source: cmdpolicy.ResolveSource{
|
Source: cmdpolicy.ResolveSource{
|
||||||
Kind: cmdpolicy.SourcePlugin,
|
Kind: cmdpolicy.SourcePlugin,
|
||||||
Name: "secaudit",
|
Name: "secaudit",
|
||||||
@@ -83,12 +83,16 @@ func TestConfigPolicyShow_PluginActive(t *testing.T) {
|
|||||||
if got["denied_paths"] != float64(42) {
|
if got["denied_paths"] != float64(42) {
|
||||||
t.Errorf("denied_paths = %v, want 42", got["denied_paths"])
|
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 {
|
if !ok {
|
||||||
t.Fatalf("rule field missing or wrong type")
|
t.Fatalf("rules[0] wrong type")
|
||||||
}
|
}
|
||||||
if ruleMap["name"] != "secaudit" {
|
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)
|
t.Cleanup(cmdpolicy.ResetActiveForTesting)
|
||||||
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rule: &platform.Rule{Name: "my-yaml-rule"},
|
Rules: []*platform.Rule{{Name: "my-yaml-rule"}},
|
||||||
Source: cmdpolicy.ResolveSource{
|
Source: cmdpolicy.ResolveSource{
|
||||||
Kind: cmdpolicy.SourceYAML,
|
Kind: cmdpolicy.SourceYAML,
|
||||||
Name: "/Users/alice/.lark-cli/policy.yml",
|
Name: "/Users/alice/.lark-cli/policy.yml",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
@@ -42,14 +43,14 @@ func configRemoveRun(opts *ConfigRemoveOptions) error {
|
|||||||
|
|
||||||
config, err := core.LoadMultiAppConfig()
|
config, err := core.LoadMultiAppConfig()
|
||||||
if err != nil || config == nil || len(config.Apps) == 0 {
|
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
|
// 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.
|
// existing config can still be retried instead of ending up half-removed.
|
||||||
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
empty := &core.MultiAppConfig{Apps: []core.AppConfig{}}
|
||||||
if err := core.SaveMultiAppConfig(empty); err != nil {
|
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.
|
// Clean up keychain entries for all apps after config is cleared.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -47,14 +48,14 @@ func configShowRun(opts *ConfigShowOptions) error {
|
|||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return core.NotConfiguredError()
|
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 {
|
if config == nil || len(config.Apps) == 0 {
|
||||||
return core.NotConfiguredError()
|
return core.NotConfiguredError()
|
||||||
}
|
}
|
||||||
app := config.CurrentAppConfig(f.Invocation.Profile)
|
app := config.CurrentAppConfig(f.Invocation.Profile)
|
||||||
if app == nil {
|
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)"
|
users := "(no logged-in users)"
|
||||||
if len(app.Users) > 0 {
|
if len(app.Users) > 0 {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
"github.com/spf13/cobra"
|
"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 {
|
func resetStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.AppConfig, global bool, args []string) error {
|
||||||
if global {
|
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 {
|
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
|
app.StrictMode = nil
|
||||||
if err := core.SaveMultiAppConfig(multi); err != 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)")
|
fmt.Fprintln(f.IOStreams.ErrOut, "Profile strict-mode reset (inherits global)")
|
||||||
return nil
|
return nil
|
||||||
@@ -104,7 +104,7 @@ func setStrictMode(f *cmdutil.Factory, multi *core.MultiAppConfig, app *core.App
|
|||||||
switch mode {
|
switch mode {
|
||||||
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
case core.StrictModeBot, core.StrictModeUser, core.StrictModeOff:
|
||||||
default:
|
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
|
// 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 {
|
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) {
|
if oldMode == core.StrictModeBot && (mode == core.StrictModeUser || mode == core.StrictModeOff) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/identitydiag"
|
"github.com/larksuite/cli/internal/identitydiag"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
|
"github.com/larksuite/cli/internal/transport"
|
||||||
"github.com/larksuite/cli/internal/update"
|
"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"
|
mcpURL := ep.MCP + "/mcp"
|
||||||
|
|
||||||
type probeResult struct {
|
type probeResult struct {
|
||||||
|
|||||||
@@ -23,12 +23,8 @@ import (
|
|||||||
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
// applyNeedAuthorizationHint augments a typed *errs.AuthenticationError with a
|
||||||
// "current command requires scope(s): X, Y" hint when the underlying error is
|
// "current command requires scope(s): X, Y" hint when the underlying error is
|
||||||
// a need_user_authorization signal AND the current command declares scopes
|
// a need_user_authorization signal AND the current command declares scopes
|
||||||
// locally (via shortcut registration or service-method metadata).
|
// locally (via shortcut registration or service-method metadata). Existing
|
||||||
//
|
// Hint text is preserved; scopes are appended on a new line.
|
||||||
// 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.
|
|
||||||
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
||||||
if err == nil || f == nil {
|
if err == nil || f == nil {
|
||||||
return
|
return
|
||||||
@@ -55,12 +51,10 @@ func applyNeedAuthorizationHint(f *cmdutil.Factory, err error) {
|
|||||||
// enrichMissingScopeError appends a "current command requires scope(s): X"
|
// enrichMissingScopeError appends a "current command requires scope(s): X"
|
||||||
// hint to a legacy *output.ExitError when the underlying error carries the
|
// hint to a legacy *output.ExitError when the underlying error carries the
|
||||||
// need_user_authorization marker AND the current command declares scopes
|
// need_user_authorization marker AND the current command declares scopes
|
||||||
// locally. Matches pre-PR behaviour byte-for-byte; lives on the legacy
|
// locally.
|
||||||
// envelope path until per-domain stage-2 typed migration.
|
|
||||||
//
|
//
|
||||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError surface.
|
// Deprecated: enrichment for the legacy envelope; the typed path is
|
||||||
// Stage-2 typed migration will lift this into AuthenticationError.Hint on
|
// applyNeedAuthorizationHint above.
|
||||||
// the typed envelope via applyNeedAuthorizationHint and remove this helper.
|
|
||||||
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
func enrichMissingScopeError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||||
if exitErr == nil || exitErr.Detail == nil {
|
if exitErr == nil || exitErr.Detail == nil {
|
||||||
return
|
return
|
||||||
@@ -155,47 +149,7 @@ func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []s
|
|||||||
if methodMap == nil {
|
if methodMap == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return declaredScopesForMethod(methodMap, identity)
|
return registry.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
// shortcutSupportsIdentity reports whether a shortcut supports the requested
|
||||||
|
|||||||
@@ -36,47 +36,71 @@ const userPolicyFileName = "policy.yml"
|
|||||||
// pluginRules carries Plugin.Restrict() contributions collected from
|
// pluginRules carries Plugin.Restrict() contributions collected from
|
||||||
// the InstallAll phase; nil/empty is fine.
|
// the InstallAll phase; nil/empty is fine.
|
||||||
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
func applyUserPolicyPruning(rootCmd *cobra.Command, pluginRules []cmdpolicy.PluginRule) error {
|
||||||
yamlPath, err := userPolicyPath()
|
// Plugin rules shadow the yaml source entirely (Resolve: plugin >
|
||||||
if err != nil {
|
// yaml). When a plugin contributed rules we therefore do NOT even
|
||||||
// No user home dir means we cannot locate the policy. Treat
|
// read ~/.lark-cli/policy.yml: build.go fail-CLOSES on any policy
|
||||||
// the same as "file missing": no pruning, no error. This keeps
|
// error once a plugin is present, so reading a malformed yaml here
|
||||||
// non-interactive CI environments (no HOME set) running.
|
// would let an unrelated broken file on the user's machine abort a
|
||||||
yamlPath = ""
|
// 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)
|
rules, source, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
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{
|
|
||||||
PluginRules: pluginRules,
|
PluginRules: pluginRules,
|
||||||
YAMLRule: yamlRule,
|
YAMLRules: yamlRules,
|
||||||
YAMLPath: yamlPath,
|
YAMLPath: yamlPath,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cmdpolicy.SetActive(nil)
|
cmdpolicy.SetActive(nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if rule == nil {
|
if len(rules) == 0 {
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{Source: source})
|
||||||
return nil
|
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)
|
decisions := engine.EvaluateAll(rootCmd)
|
||||||
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, rule.Name)
|
denied := cmdpolicy.BuildDeniedByPath(rootCmd, decisions, source, ruleName)
|
||||||
cmdpolicy.Apply(rootCmd, denied)
|
cmdpolicy.Apply(rootCmd, denied)
|
||||||
|
|
||||||
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
cmdpolicy.SetActive(&cmdpolicy.ActivePolicy{
|
||||||
Rule: rule,
|
Rules: rules,
|
||||||
Source: source,
|
Source: source,
|
||||||
DeniedPaths: len(denied),
|
DeniedPaths: len(denied),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"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
|
// Semantically-invalid Rule (bad MaxRisk) reaches ValidateRule inside
|
||||||
// Resolve and produces an error. This is the safety contract: a typo in
|
// Resolve and produces an error. This is the safety contract: a typo in
|
||||||
// the rule must not silently lower the pruning bar.
|
// the rule must not silently lower the pruning bar.
|
||||||
|
|||||||
196
cmd/root.go
196
cmd/root.go
@@ -4,23 +4,22 @@
|
|||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/build"
|
"github.com/larksuite/cli/internal/build"
|
||||||
"github.com/larksuite/cli/internal/cmdpolicy"
|
"github.com/larksuite/cli/internal/cmdpolicy"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"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/hook"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
@@ -201,43 +200,59 @@ func configureFlagCompletions(args []string) {
|
|||||||
// and returns the process exit code.
|
// and returns the process exit code.
|
||||||
//
|
//
|
||||||
// Dispatch order:
|
// Dispatch order:
|
||||||
// 1. *errs.SecurityPolicyError: keeps the legacy custom envelope
|
// 1. Legacy shapes (*core.ConfigError, *internalauth.NeedAuthorizationError)
|
||||||
// (type=auth_error, string code, retryable, challenge_url) and exit 1.
|
// are promoted via errcompat to their typed errs/ counterparts, with the
|
||||||
// Carve-out from the typed taxonomy — wire migration deferred to a later PR.
|
// original preserved in the Cause chain.
|
||||||
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError):
|
// 2. Typed errors from errs/ (e.g. *errs.PermissionError, *errs.APIError,
|
||||||
// render via the typed envelope writer, which lifts extension fields
|
// *errs.SecurityPolicyError, *errs.AuthenticationError): render via the
|
||||||
// (missing_scopes, console_url, ...) to the top level. Routed by
|
// typed envelope writer, which lifts extension fields (missing_scopes,
|
||||||
|
// console_url, challenge_url, ...) to the top level. Routed by
|
||||||
// errs.CategoryOf via ExitCodeOf.
|
// errs.CategoryOf via ExitCodeOf.
|
||||||
// 3. *core.ConfigError + Legacy *output.ExitError: asExitError adapts them
|
// 3. Legacy *output.ExitError: asExitError adapts it to the legacy
|
||||||
// to a legacy envelope; written via WriteErrorEnvelope. Stage-1 keeps
|
// envelope, written via WriteErrorEnvelope.
|
||||||
// this path so existing wire shapes are preserved byte-for-byte until
|
|
||||||
// per-domain typed migration in stage 2+.
|
|
||||||
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
|
// 4. Cobra errors (required flags, unknown commands, etc.): plain text.
|
||||||
func handleRootError(f *cmdutil.Factory, err error) int {
|
func handleRootError(f *cmdutil.Factory, err error) int {
|
||||||
errOut := f.IOStreams.ErrOut
|
errOut := f.IOStreams.ErrOut
|
||||||
|
|
||||||
// SecurityPolicyError keeps the legacy custom envelope (string codes,
|
// Promote legacy error shapes into typed errs/ before envelope marshal.
|
||||||
// challenge_url, retryable) and exit code 1 — its wire shape predates the
|
// NeedAuthorizationError check is first because it is the more specific
|
||||||
// typed taxonomy and downstream OAuth/policy consumers depend on it.
|
// shape; *core.ConfigError check follows. errors.As preserves the original
|
||||||
// The taxonomy migration for this category is deferred to a later PR.
|
// in the Cause chain, so external errors.As(&core.ConfigError{}) consumers
|
||||||
var spErr *errs.SecurityPolicyError
|
// (cmd/auth/list.go, cmd/doctor/doctor.go, ...) still match.
|
||||||
if errors.As(err, &spErr) {
|
//
|
||||||
writeSecurityPolicyError(errOut, spErr)
|
// Outer-typed short-circuit: if err is already a typed *errs.* error,
|
||||||
return 1
|
// 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
|
// 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
|
// 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
|
// 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.
|
// local shortcut/service metadata — it never depends on server state.
|
||||||
applyNeedAuthorizationHint(f, err)
|
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)) {
|
if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
|
||||||
return output.ExitCodeOf(err)
|
return typedExit
|
||||||
}
|
}
|
||||||
|
|
||||||
if exitErr := asExitError(err); exitErr != nil {
|
if exitErr := asExitError(err); exitErr != nil {
|
||||||
@@ -256,52 +271,19 @@ func handleRootError(f *cmdutil.Factory, err error) int {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeSecurityPolicyError writes the security-policy-specific JSON envelope.
|
// isOuterTypedError returns true if err is a typed *errs.* error AT THE
|
||||||
// This wire format intentionally differs from the typed envelope writer: it
|
// TOP OF THE CHAIN (not buried inside Unwrap). Used by handleRootError
|
||||||
// uses string codes ("challenge_required"/"access_denied"), a "auth_error"
|
// to gate PromoteXxxError so a producer's outer typed envelope is never
|
||||||
// type literal, and a top-level "retryable" field — the shape OAuth/policy
|
// overwritten by a coarser shape derived from its legacy Cause.
|
||||||
// consumers have been parsing since before the typed taxonomy existed.
|
func isOuterTypedError(err error) bool {
|
||||||
func writeSecurityPolicyError(w io.Writer, spErr *errs.SecurityPolicyError) {
|
_, ok := err.(errs.TypedError)
|
||||||
var codeStr string
|
return ok
|
||||||
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())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// asExitError converts known structured error types to *output.ExitError.
|
// asExitError converts known structured error types to *output.ExitError.
|
||||||
// Returns nil for unrecognized errors (e.g. cobra flag errors).
|
// 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 {
|
func asExitError(err error) *output.ExitError {
|
||||||
var cfgErr *core.ConfigError
|
var cfgErr *core.ConfigError
|
||||||
if errors.As(err, &cfgErr) {
|
if errors.As(err, &cfgErr) {
|
||||||
@@ -417,65 +399,55 @@ func installTipsHelpFunc(root *cobra.Command) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// enrichPermissionError adds console_url and improves the hint for legacy
|
// enrichPermissionError rewrites the legacy *output.ExitError envelope so its
|
||||||
// *output.ExitError permission errors. Differentiates between:
|
// Message + Hint match the per-subtype canonical text produced by the typed
|
||||||
// - LarkErrAppScopeNotEnabled (99991672): app has not enabled the scope
|
// dispatcher path (errclass.CanonicalPermissionMessage / errclass.PermissionHint).
|
||||||
// - LarkErrUserScopeInsufficient (99991679) / LarkErrUserNotAuthorized:
|
// This guarantees a caller observing the wire envelope cannot tell whether
|
||||||
// user has not authorized the scope → hint to auth login
|
// the error reached the dispatcher via the legacy *ExitError bridge or via
|
||||||
// - default: other permission errors → console + auth-login fallback
|
// the typed *errs.PermissionError fast path.
|
||||||
//
|
//
|
||||||
// Deprecated: stage-1 enrichment for the legacy *output.ExitError envelope.
|
// Deprecated: legacy *output.ExitError enrichment; typed PermissionError
|
||||||
// Stage-2 typed migration will lift this into PermissionError.MissingScopes
|
// values produced by errclass.BuildAPIError already carry MissingScopes +
|
||||||
// + ConsoleURL on the typed envelope and remove this helper.
|
// ConsoleURL directly.
|
||||||
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
func enrichPermissionError(f *cmdutil.Factory, exitErr *output.ExitError) {
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "permission" {
|
if exitErr.Detail == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Extract required scopes from API error detail (shared helper)
|
// Only the legacy permission-class envelope types route here. "app_status"
|
||||||
scopes := registry.ExtractRequiredScopes(exitErr.Detail.Detail)
|
// covers 99991662 (app_disabled) / 99991673 (app_unavailable); "permission"
|
||||||
if len(scopes) == 0 {
|
// covers the four scope-class codes (99991672 / 99991676 / 99991679 / 230027).
|
||||||
|
if exitErr.Detail.Type != "permission" && exitErr.Detail.Type != "app_status" {
|
||||||
return
|
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()
|
cfg, err := f.Config()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the recommended (least-privilege) scope
|
// Reuse the same console URL builder as the typed path so both wire
|
||||||
recommended := registry.SelectRecommendedScopeFromStrings(scopes, "tenant")
|
// envelopes carry identical console_url values for the same input.
|
||||||
|
consoleURL := errclass.ConsoleURL(string(cfg.Brand), cfg.AppID, missing)
|
||||||
// Build admin console URL with the recommended scope
|
|
||||||
consoleURL := registry.BuildConsoleScopeURL(cfg.Brand, cfg.AppID, recommended)
|
|
||||||
|
|
||||||
// Clear raw API detail — useful info is now in message/hint/console_url.
|
// Clear raw API detail — useful info is now in message/hint/console_url.
|
||||||
exitErr.Detail.Detail = nil
|
exitErr.Detail.Detail = nil
|
||||||
|
|
||||||
isBot := f.ResolvedIdentity.IsBot()
|
identity := string(f.ResolvedIdentity)
|
||||||
larkCode := exitErr.Detail.Code
|
if identity == "" {
|
||||||
switch larkCode {
|
identity = "user"
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
OK: false,
|
||||||
Identity: "bot",
|
Identity: "bot",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "command_denied",
|
Type: "validation",
|
||||||
Message: `strict mode is "user", only user-identity commands are available`,
|
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)",
|
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,
|
OK: false,
|
||||||
Identity: "user",
|
Identity: "user",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "command_denied",
|
Type: "validation",
|
||||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
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)",
|
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,
|
OK: false,
|
||||||
Identity: "user",
|
Identity: "user",
|
||||||
Error: &output.ErrDetail{
|
Error: &output.ErrDetail{
|
||||||
Type: "command_denied",
|
Type: "validation",
|
||||||
Message: `strict mode is "bot", only bot-identity commands are available`,
|
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)",
|
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"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
internalauth "github.com/larksuite/cli/internal/auth"
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,81 +139,96 @@ func TestIsCompletionCommand(t *testing.T) {
|
|||||||
// TestPromoteConfigError_* lives with the implementation in
|
// TestPromoteConfigError_* lives with the implementation in
|
||||||
// internal/errcompat/promote_test.go.
|
// internal/errcompat/promote_test.go.
|
||||||
|
|
||||||
// TestHandleRootError_SecurityPolicyKeepsLegacyEnvelope pins the carve-out
|
// TestHandleRootError_SecurityPolicyCanonicalEnvelope verifies that
|
||||||
// for *errs.SecurityPolicyError: it does NOT go through the typed envelope
|
// *errs.SecurityPolicyError flows through the canonical typed envelope
|
||||||
// writer. Downstream OAuth/policy consumers parse a wire format that
|
// (output.WriteTypedErrorEnvelope) — type=policy, numeric code, subtype,
|
||||||
// predates the typed taxonomy and depend on:
|
// top-level identity, exit code 6 — after the dispatcher carve-out is removed.
|
||||||
// - error.type == "auth_error" (not the Category literal "policy")
|
func TestHandleRootError_SecurityPolicyCanonicalEnvelope(t *testing.T) {
|
||||||
// - 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) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
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("21000 challenge_required", func(t *testing.T) {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
errOut := &bytes.Buffer{}
|
||||||
errOut := &bytes.Buffer{}
|
f.IOStreams.ErrOut = errOut
|
||||||
f.IOStreams.ErrOut = errOut
|
|
||||||
|
|
||||||
spErr := &errs.SecurityPolicyError{
|
spErr := &errs.SecurityPolicyError{
|
||||||
Problem: errs.Problem{
|
Problem: errs.Problem{
|
||||||
Category: errs.CategoryPolicy,
|
Category: errs.CategoryPolicy,
|
||||||
Subtype: tc.subtype,
|
Subtype: errs.SubtypeChallengeRequired,
|
||||||
Code: tc.code,
|
Code: 21000,
|
||||||
Message: "blocked by access policy",
|
Message: "blocked by access policy",
|
||||||
Hint: "complete challenge in your browser",
|
Hint: "complete challenge in your browser",
|
||||||
},
|
},
|
||||||
ChallengeURL: "https://example.com/challenge",
|
ChallengeURL: "https://example.com/challenge",
|
||||||
}
|
}
|
||||||
|
|
||||||
gotExit := handleRootError(f, spErr)
|
gotExit := handleRootError(f, spErr)
|
||||||
if gotExit != 1 {
|
if gotExit != int(output.ExitContentSafety) {
|
||||||
t.Errorf("exit code = %d, want 1 (legacy carve-out)", gotExit)
|
t.Errorf("exit code = %d, want %d (ExitContentSafety)", gotExit, output.ExitContentSafety)
|
||||||
}
|
}
|
||||||
|
|
||||||
var env map[string]any
|
var env map[string]any
|
||||||
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
if err := json.Unmarshal(errOut.Bytes(), &env); err != nil {
|
||||||
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
t.Fatalf("envelope is not valid JSON: %v\n%s", err, errOut.String())
|
||||||
}
|
}
|
||||||
errObj, ok := env["error"].(map[string]any)
|
errObj, ok := env["error"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
|
t.Fatalf("envelope missing top-level error object: %s", errOut.String())
|
||||||
}
|
}
|
||||||
if got := errObj["type"]; got != "auth_error" {
|
if got := errObj["type"]; got != "policy" {
|
||||||
t.Errorf("error.type = %v, want %q", got, "auth_error")
|
t.Errorf("error.type = %v, want %q", got, "policy")
|
||||||
}
|
}
|
||||||
if got := errObj["code"]; got != tc.wantCode {
|
if got := errObj["subtype"]; got != "challenge_required" {
|
||||||
t.Errorf("error.code = %v (%T), want %q (string)", got, got, tc.wantCode)
|
t.Errorf("error.subtype = %v, want %q", got, "challenge_required")
|
||||||
}
|
}
|
||||||
if got, ok := errObj["retryable"].(bool); !ok || got {
|
if got, ok := errObj["code"].(float64); !ok || int(got) != 21000 {
|
||||||
t.Errorf("error.retryable = %v (%T), want false (bool)", errObj["retryable"], errObj["retryable"])
|
t.Errorf("error.code = %v (%T), want 21000 (number)", errObj["code"], errObj["code"])
|
||||||
}
|
}
|
||||||
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
|
if got := errObj["challenge_url"]; got != "https://example.com/challenge" {
|
||||||
t.Errorf("error.challenge_url = %v, want challenge url", got)
|
t.Errorf("error.challenge_url = %v, want challenge url", got)
|
||||||
}
|
}
|
||||||
if got := errObj["hint"]; got != "complete challenge in your browser" {
|
if got := errObj["hint"]; got != "complete challenge in your browser" {
|
||||||
t.Errorf("error.hint = %v, want hint message", got)
|
t.Errorf("error.hint = %v, want hint message", got)
|
||||||
}
|
}
|
||||||
// And the typed-only fields must NOT appear on this envelope.
|
if _, exists := errObj["retryable"]; exists {
|
||||||
for _, leaked := range []string{"subtype", "missing_scopes", "console_url"} {
|
t.Errorf("error.retryable leaked into canonical envelope: %v", errObj["retryable"])
|
||||||
if _, exists := errObj[leaked]; exists {
|
}
|
||||||
t.Errorf("error.%s leaked into legacy security envelope: %v", leaked, errObj[leaked])
|
})
|
||||||
}
|
|
||||||
}
|
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
|
// 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
|
// TestApplyNeedAuthorizationHint_ServiceMethodUsesLocalScopesWhenNoUAT pins
|
||||||
// that a typed AuthenticationError carrying the need_user_authorization marker gets a
|
// that a typed AuthenticationError carrying the need_user_authorization marker gets a
|
||||||
// declared-scopes Hint appended when the current command is a registered
|
// 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)
|
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"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/client"
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/cmdutil"
|
"github.com/larksuite/cli/internal/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
@@ -222,7 +224,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.PageAll && opts.Output != "" {
|
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 {
|
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||||
return err
|
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)
|
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 1: enrich the 99991679 (LarkErrUserScopeInsufficient) response
|
// Scope-insufficient (99991679) and all other Lark API codes route through
|
||||||
// with a per-method recommended `--scope` hint, matching the pre-PR
|
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
|
||||||
// behaviour. Per-domain typed migration in stage 2+ will lift this
|
// with MissingScopes / Identity / ConsoleURL populated from the response.
|
||||||
// into PermissionError.MissingScopes / ConsoleURL on the typed
|
checkErr := ac.CheckResponse
|
||||||
// envelope; until then the legacy ExitError envelope is preserved.
|
|
||||||
checkErr := scopeAwareChecker(scopes, opts.As.IsBot())
|
|
||||||
|
|
||||||
if opts.PageAll {
|
if opts.PageAll {
|
||||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut,
|
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.
|
// 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 {
|
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 {
|
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 {
|
if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 {
|
||||||
return output.ErrWithHint(output.ExitAuth, "missing_scope",
|
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing)
|
||||||
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 nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -388,9 +341,24 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
recommended := registry.SelectRecommendedScope(scopes, "user")
|
recommended := registry.SelectRecommendedScope(scopes, "user")
|
||||||
return output.ErrWithHint(output.ExitAPI, "permission",
|
return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended})
|
||||||
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))
|
|
||||||
|
// 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.
|
// 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
|
return client.RawApiRequest{}, nil, err
|
||||||
}
|
}
|
||||||
if opts.Params == "-" && opts.Data == "-" {
|
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)
|
params, err := cmdutil.ParseJSONMap(opts.Params, "--params", stdin, fileIO)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -429,13 +397,14 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
|||||||
}
|
}
|
||||||
val, ok := params[name]
|
val, ok := params[name]
|
||||||
if !ok || util.IsEmptyValue(val) {
|
if !ok || util.IsEmptyValue(val) {
|
||||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
fmt.Sprintf("missing required path parameter: %s", name),
|
"missing required path parameter: %s", name).
|
||||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
WithHint("lark-cli schema %s", schemaPath).
|
||||||
|
WithParam(name)
|
||||||
}
|
}
|
||||||
valStr := fmt.Sprintf("%v", val)
|
valStr := fmt.Sprintf("%v", val)
|
||||||
if err := validate.ResourceName(valStr, name); err != nil {
|
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)
|
url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1)
|
||||||
delete(params, name)
|
delete(params, name)
|
||||||
@@ -451,9 +420,10 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
|||||||
required, _ := p["required"].(bool)
|
required, _ := p["required"].(bool)
|
||||||
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size")
|
||||||
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) {
|
||||||
return client.RawApiRequest{}, nil, output.ErrWithHint(output.ExitValidation, "validation",
|
return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
fmt.Sprintf("missing required query parameter: %s", name),
|
"missing required query parameter: %s", name).
|
||||||
fmt.Sprintf("lark-cli schema %s", schemaPath))
|
WithHint("lark-cli schema %s", schemaPath).
|
||||||
|
WithParam(name)
|
||||||
}
|
}
|
||||||
if exists && !util.IsEmptyValue(value) {
|
if exists && !util.IsEmptyValue(value) {
|
||||||
queryParams[name] = value
|
queryParams[name] = value
|
||||||
@@ -488,7 +458,7 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
|||||||
return client.RawApiRequest{}, nil, err
|
return client.RawApiRequest{}, nil, err
|
||||||
}
|
}
|
||||||
if _, ok := dataFields.(map[string]any); !ok {
|
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
|
6. Wrapping is idempotent: re-wrapping an already-typed error returns it
|
||||||
unchanged across the `errors.As` / `errors.Unwrap` chain.
|
unchanged across the `errors.As` / `errors.Unwrap` chain.
|
||||||
7. For the typed-envelope path, exit codes derive from `Category` only
|
7. For the typed-envelope path, exit codes derive from `Category` only
|
||||||
via `output.ExitCodeForCategory`. Two stage-1 exceptions:
|
via `output.ExitCodeForCategory` — including `SecurityPolicyError`,
|
||||||
`SecurityPolicyError` always exits `1` (fixed by its legacy envelope),
|
which exits `6` via `CategoryPolicy`. Unmigrated `*output.ExitError`
|
||||||
and unmigrated `*output.ExitError` producers carry a hand-set `Code`;
|
producers still carry a hand-set `Code` until they finish migrating.
|
||||||
both are retired in the legacy-removal stage.
|
`output.ErrBare(code)` is the lone exception: a deliberate
|
||||||
|
predicate-command signal that bypasses the envelope (see
|
||||||
|
**Predicate commands** below).
|
||||||
|
|
||||||
## Wire format
|
## 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` |
|
| `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` |
|
| per-Subtype extension fields | per-Subtype-stable | e.g. `missing_scopes`, `console_url`, `challenge_url` |
|
||||||
|
|
||||||
Carve-out: `SecurityPolicyError` keeps the legacy
|
`SecurityPolicyError` renders through the same typed envelope as every
|
||||||
`{type: "auth_error", code: "challenge_required"|"access_denied", ...}`
|
other category. `error.type` is `"policy"`, `error.subtype` is one of
|
||||||
envelope until its consumers migrate. Removal is staged in **Migration**.
|
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||||
|
`CategoryPolicy`. The legacy `auth_error` envelope at exit `1` has been
|
||||||
|
retired.
|
||||||
|
|
||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
@@ -115,10 +119,11 @@ Canonical mapping: `internal/output/exitcode.go` `ExitCodeForCategory`.
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
cmd/root.go handleRootError dispatches:
|
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)
|
├─ typed (errs.ProblemOf) → typed JSON envelope; exit = ExitCodeOf(err)
|
||||||
├─ *core.ConfigError → asExitError adapts to legacy envelope ↓
|
│ (includes *errs.SecurityPolicyError → policy envelope, exit 6)
|
||||||
├─ *output.ExitError → legacy JSON envelope; exit = exitErr.Code
|
├─ *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
|
└─ untyped / Cobra error → plain "Error: <msg>" (no envelope); exit 1
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,6 +132,31 @@ stderr. Untyped errors (including Cobra's "required flag missing" / unknown
|
|||||||
subcommand messages) print plain text and exit `1` — consumers must
|
subcommand messages) print plain text and exit `1` — consumers must
|
||||||
tolerate that fallback.
|
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`.
|
||||||
|
|
||||||
## Consumers
|
## Consumers
|
||||||
|
|
||||||
### Go (in-process)
|
### Go (in-process)
|
||||||
@@ -183,17 +213,25 @@ reworded without notice.
|
|||||||
|
|
||||||
### Quick reference
|
### 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 |
|
| Situation | Use |
|
||||||
|-----------|-----|
|
|-----------|-----|
|
||||||
| Bad user input | `&errs.ValidationError{...}` or `output.ErrValidation(msg)` |
|
| Bad user input | `errs.NewValidationError(subtype, msg).WithParam("--flag")` |
|
||||||
| Login required | `&errs.AuthenticationError{...}` |
|
| Login required | `errs.NewAuthenticationError(errs.SubtypeTokenMissing, msg)` |
|
||||||
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
|
| Token lacks scope | `errclass.BuildAPIError(resp, ctx)` |
|
||||||
| Local config missing | `&errs.ConfigError{...}` |
|
| Local config missing | `errs.NewConfigError(errs.SubtypeNotConfigured, msg)` |
|
||||||
| Transport failure | `&errs.NetworkError{...}` |
|
| Transport failure | `errs.NewNetworkError(errs.SubtypeNetworkTimeout, msg).WithCause(err)` (subtype: `timeout` / `tls` / `dns` / `server_error` / `transport`) |
|
||||||
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
|
| Lark API error | `errclass.BuildAPIError(resp, ctx)` |
|
||||||
| SDK / decode bug | `&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, ...}}` |
|
| SDK / decode bug | `errs.NewInternalError(errs.SubtypeSDKError, msg).WithCause(err)` |
|
||||||
| Policy block | `&errs.SecurityPolicyError{...}` or `&errs.ContentSafetyError{...}` |
|
| Policy block | `errs.NewSecurityPolicyError(subtype, msg).WithChallengeURL(url)` or `errs.NewContentSafetyError(subtype, msg).WithRules(...)` |
|
||||||
| Needs `--yes` | `&errs.ConfirmationRequiredError{...}` |
|
| Needs `--yes` | `errs.NewConfirmationRequiredError(risk, action, msg)` |
|
||||||
|
|
||||||
### Authoring discipline
|
### Authoring discipline
|
||||||
|
|
||||||
@@ -242,8 +280,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
|
maps `Category` to the shell code. A new exit-code requirement means a
|
||||||
new `Category`, not a one-off override at the call site.
|
new `Category`, not a one-off override at the call site.
|
||||||
|
|
||||||
(Legacy `*output.ExitError` and `SecurityPolicyError` retain hand-set
|
(Legacy `*output.ExitError` retains hand-set codes until removal;
|
||||||
codes during stage 1.)
|
`SecurityPolicyError` retains a hand-set code on main until the framework
|
||||||
|
migration PR retires the carve-out — see **Migration**.)
|
||||||
|
|
||||||
#### Split `Message`, `Hint`, and `Cause`
|
#### Split `Message`, `Hint`, and `Cause`
|
||||||
|
|
||||||
@@ -265,15 +304,10 @@ do not inline its `.Error()` into `Message`.
|
|||||||
Conforming:
|
Conforming:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
return &errs.NetworkError{
|
return errs.NewNetworkError(errs.SubtypeNetworkTransport,
|
||||||
Problem: errs.Problem{
|
"request to /open-apis failed after 3 retries").
|
||||||
Category: errs.CategoryNetwork,
|
WithHint("check connectivity and retry; set --log-level debug if it persists").
|
||||||
Subtype: errs.SubtypeNetworkTransport,
|
WithCause(ioErr)
|
||||||
Message: "request to /open-apis failed after 3 retries",
|
|
||||||
Hint: "check connectivity and retry; set --log-level debug if it persists",
|
|
||||||
},
|
|
||||||
Cause: ioErr,
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Non-conforming:
|
Non-conforming:
|
||||||
@@ -294,43 +328,51 @@ For positional arguments, use the canonical name without dashes
|
|||||||
|
|
||||||
### Constructing typed errors
|
### 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
|
```go
|
||||||
return &errs.ValidationError{
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
Problem: errs.Problem{
|
"--data must be a valid JSON object: %v", parseErr).
|
||||||
Category: errs.CategoryValidation,
|
WithParam("--data")
|
||||||
Subtype: errs.SubtypeInvalidArgument,
|
|
||||||
Message: fmt.Sprintf("--data must be a valid JSON object: %v", parseErr),
|
|
||||||
},
|
|
||||||
Param: "--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`)
|
Legacy helpers (`output.ErrValidation`, `output.ErrAuth`, `output.ErrNetwork`)
|
||||||
remain callable during migration; new code should prefer the struct
|
remain callable during migration but are `// Deprecated:` — new code goes
|
||||||
literal so `Hint`, `Param`, `Cause`, and other extension fields stay
|
through the builder.
|
||||||
available per [Split `Message`, `Hint`, and `Cause`](#split-message-hint-and-cause).
|
|
||||||
|
|
||||||
#### Shortcut `Execute` walkthrough
|
#### Shortcut `Execute` walkthrough
|
||||||
|
|
||||||
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
|
Adapted from `shortcuts/calendar/calendar_suggestion.go:222`, whose legacy
|
||||||
form is `output.ErrValidation("--duration-minutes must be between 1 and
|
form is `output.ErrValidation("--duration-minutes must be between 1 and
|
||||||
1440")`. The typed migration target:
|
1440")`. The typed migration target (builder form):
|
||||||
|
|
||||||
```go
|
```go
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
duration := runtime.Int("duration-minutes")
|
duration := runtime.Int("duration-minutes")
|
||||||
if duration < 1 || duration > 1440 {
|
if duration < 1 || duration > 1440 {
|
||||||
return &errs.ValidationError{
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
Problem: errs.Problem{
|
"--duration-minutes must be between 1 and 1440, got %d", duration).
|
||||||
Category: errs.CategoryValidation,
|
WithHint("pass a value in [1, 1440]").
|
||||||
Subtype: errs.SubtypeInvalidArgument,
|
WithParam("--duration-minutes")
|
||||||
Message: fmt.Sprintf("--duration-minutes must be between 1 and 1440, got %d", duration),
|
|
||||||
Hint: "pass a value in [1, 1440]",
|
|
||||||
},
|
|
||||||
Param: "--duration-minutes",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := runtime.DoAPI(req, opts)
|
_, err := runtime.DoAPI(req, opts)
|
||||||
@@ -360,7 +402,7 @@ cover the decision:
|
|||||||
| Source | Decision | Example |
|
| Source | Decision | Example |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| Helper returned a typed `*errs.*Error` | Return unchanged | `return err` |
|
| 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` |
|
| 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))` |
|
| 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 +433,11 @@ through `runtime.DoAPI`.
|
|||||||
|
|
||||||
#### Add a Subtype
|
#### Add a Subtype
|
||||||
|
|
||||||
1. Add a constant in `errs/subtypes.go` (framework) or
|
1. Add a constant in `errs/subtypes.go` under the right Category block.
|
||||||
`errs/subtypes_service_<name>.go` (service).
|
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
|
2. If it maps from a Lark code, register the mapping in
|
||||||
`internal/errclass/codemeta_<service>.go`.
|
`internal/errclass/codemeta_<service>.go`.
|
||||||
3. Add a dispatch test in `internal/errclass/classify_test.go`.
|
3. Add a dispatch test in `internal/errclass/classify_test.go`.
|
||||||
@@ -409,10 +454,9 @@ emits a warning to keep them visible.
|
|||||||
|
|
||||||
Rare; the existing structs cover the 9 Categories with room. If you must:
|
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
|
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.
|
||||||
nil-receiver-safe `Unwrap()` if it carries `Cause`.
|
|
||||||
2. Add an `IsXxx` predicate in `errs/predicates.go`.
|
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
|
`CheckProblemEmbed` enforces the `Problem` embed at lint time. New
|
||||||
top-level wire fields are forbidden — per-Subtype data goes into the
|
top-level wire fields are forbidden — per-Subtype data goes into the
|
||||||
@@ -448,51 +492,36 @@ will be removed once business migration completes.
|
|||||||
|
|
||||||
## Migration
|
## Migration
|
||||||
|
|
||||||
The error-contract refactor lands in stages. This PR is **stage 1**, and
|
**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.
|
||||||
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.
|
|
||||||
|
|
||||||
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,
|
### Current state
|
||||||
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`.
|
|
||||||
|
|
||||||
During migration, helper assertions accept both shapes (see
|
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.
|
||||||
`shortcuts/mail/mail_shortcut_validation_test.go` `assertValidationError`)
|
|
||||||
so the build stays green domain-by-domain.
|
|
||||||
|
|
||||||
Before / after at a call site (illustrative — actually performed in
|
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.
|
||||||
stage 3):
|
|
||||||
|
### 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
|
```go
|
||||||
// before (legacy)
|
// before (legacy)
|
||||||
@@ -502,6 +531,16 @@ return output.ErrAPI(larkCode, "create event failed", resp.RawBody())
|
|||||||
return errclass.BuildAPIError(parsedResp, cc)
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
**Envelope shows `type=api subtype=unknown` for what should be a more
|
**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) {
|
func TestValidationError_MarshalJSON(t *testing.T) {
|
||||||
ve := &ValidationError{
|
ve := &ValidationError{
|
||||||
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
|
Problem: Problem{Category: CategoryValidation, Subtype: SubtypeInvalidArgument, Message: "bad"},
|
||||||
@@ -116,33 +138,26 @@ func TestConfigError_MarshalJSON(t *testing.T) {
|
|||||||
|
|
||||||
func TestNetworkError_MarshalJSON(t *testing.T) {
|
func TestNetworkError_MarshalJSON(t *testing.T) {
|
||||||
ne := &NetworkError{
|
ne := &NetworkError{
|
||||||
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTransport, Message: "transport"},
|
Problem: Problem{Category: CategoryNetwork, Subtype: SubtypeNetworkTimeout, Message: "dial timeout"},
|
||||||
CauseKind: "timeout",
|
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(ne)
|
b, _ := json.Marshal(ne)
|
||||||
s := string(b)
|
s := string(b)
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`"type":"network"`,
|
`"type":"network"`,
|
||||||
`"subtype":"transport"`,
|
`"subtype":"timeout"`,
|
||||||
`"cause":"timeout"`,
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(s, want) {
|
if !strings.Contains(s, want) {
|
||||||
t.Errorf("missing %q in %s", want, s)
|
t.Errorf("missing %q in %s", want, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if strings.Contains(s, `"cause"`) {
|
||||||
// CauseKind omitempty when ""
|
t.Errorf("cause field should no longer be on the wire; got %s", s)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAPIError_MarshalJSON(t *testing.T) {
|
func TestAPIError_MarshalJSON(t *testing.T) {
|
||||||
ae := &APIError{
|
ae := &APIError{
|
||||||
Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true},
|
Problem: Problem{Category: CategoryAPI, Subtype: SubtypeRateLimit, Code: 99991400, Message: "slow", Retryable: true},
|
||||||
Detail: map[string]any{"raw": "value"},
|
|
||||||
}
|
}
|
||||||
b, _ := json.Marshal(ae)
|
b, _ := json.Marshal(ae)
|
||||||
s := string(b)
|
s := string(b)
|
||||||
@@ -151,19 +166,39 @@ func TestAPIError_MarshalJSON(t *testing.T) {
|
|||||||
`"subtype":"rate_limit"`,
|
`"subtype":"rate_limit"`,
|
||||||
`"code":99991400`,
|
`"code":99991400`,
|
||||||
`"retryable":true`,
|
`"retryable":true`,
|
||||||
`"detail":{`,
|
|
||||||
`"raw":"value"`,
|
|
||||||
} {
|
} {
|
||||||
if !strings.Contains(s, want) {
|
if !strings.Contains(s, want) {
|
||||||
t.Errorf("missing %q in %s", want, s)
|
t.Errorf("missing %q in %s", want, s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detail omitempty when nil
|
// TestProblem_MarshalJSON_Troubleshooter pins the upstream Lark API
|
||||||
ae2 := &APIError{Problem: Problem{Category: CategoryAPI, Message: "x"}}
|
// troubleshooter URL (resp.error.troubleshooter) surfacing on the wire under
|
||||||
b2, _ := json.Marshal(ae2)
|
// "troubleshooter". Carried via Problem so any typed error that embeds it
|
||||||
if strings.Contains(string(b2), `"detail"`) {
|
// inherits the field — populated by errclass.BuildAPIError before the
|
||||||
t.Errorf("detail should be omitted when nil; got %s", b2)
|
// 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) {
|
func TestContentSafetyError_MarshalJSON(t *testing.T) {
|
||||||
cse := &ContentSafetyError{
|
cse := &ContentSafetyError{
|
||||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("content_blocked"), Message: "blocked"},
|
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.
|
// IsConfig reports whether err is a *ConfigError.
|
||||||
func IsConfig(err error) bool { var x *ConfigError; return errors.As(err, &x) }
|
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.
|
// never appears on the wire.
|
||||||
// - No DocURL field. PermissionError carries the same intent via its typed
|
// - No DocURL field. PermissionError carries the same intent via its typed
|
||||||
// ConsoleURL extension; other typed errors do not link out.
|
// 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
|
// - Retryable uses omitempty so only `true` is emitted; consumers treat
|
||||||
// absence as false.
|
// absence as false.
|
||||||
type Problem struct {
|
type Problem struct {
|
||||||
Category Category `json:"type"`
|
Category Category `json:"type"`
|
||||||
Subtype Subtype `json:"subtype,omitempty"`
|
Subtype Subtype `json:"subtype,omitempty"`
|
||||||
Code int `json:"code,omitempty"`
|
Code int `json:"code,omitempty"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Hint string `json:"hint,omitempty"`
|
Hint string `json:"hint,omitempty"`
|
||||||
LogID string `json:"log_id,omitempty"`
|
LogID string `json:"log_id,omitempty"`
|
||||||
Retryable bool `json:"retryable,omitempty"`
|
Troubleshooter string `json:"troubleshooter,omitempty"`
|
||||||
|
Retryable bool `json:"retryable,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error satisfies the standard `error` interface. A nil receiver is treated
|
// Error satisfies the standard `error` interface. A nil receiver is treated
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ const (
|
|||||||
SubtypeAppScopeNotApplied Subtype = "app_scope_not_applied" // app did not apply for this scope on the open platform
|
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)
|
SubtypeTokenScopeInsufficient Subtype = "token_scope_insufficient" // token was issued without this scope (RFC 6750 alignment)
|
||||||
SubtypeAppUnavailable Subtype = "app_unavailable" // app status unavailable
|
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
|
// CategoryConfig subtypes
|
||||||
@@ -46,7 +47,11 @@ const (
|
|||||||
|
|
||||||
// CategoryNetwork subtypes
|
// CategoryNetwork subtypes
|
||||||
const (
|
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
|
// CategoryAPI subtypes
|
||||||
@@ -57,6 +62,10 @@ const (
|
|||||||
SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported)
|
SubtypeCrossBrand Subtype = "cross_brand" // operation crosses brand boundary (feishu vs lark, not supported)
|
||||||
SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request
|
SubtypeInvalidParameters Subtype = "invalid_parameters" // API-side parameter validation rejected the request
|
||||||
SubtypeOwnershipMismatch Subtype = "ownership_mismatch" // caller is not the resource owner
|
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)
|
// CategoryPolicy subtypes (security-policy envelope shape)
|
||||||
@@ -69,7 +78,12 @@ const (
|
|||||||
const (
|
const (
|
||||||
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error
|
||||||
SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON
|
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.
|
// 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"
|
|
||||||
)
|
|
||||||
655
errs/types.go
655
errs/types.go
@@ -3,6 +3,59 @@
|
|||||||
|
|
||||||
package errs
|
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.
|
// ValidationError is the typed error for CategoryValidation.
|
||||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||||
// it is intentionally not serialized.
|
// it is intentionally not serialized.
|
||||||
@@ -22,6 +75,60 @@ func (e *ValidationError) Unwrap() error {
|
|||||||
return e.Cause
|
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) WithCause(cause error) *ValidationError {
|
||||||
|
e.Cause = cause
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================== AuthenticationError =============================
|
||||||
|
|
||||||
// AuthenticationError is the typed error for CategoryAuthentication.
|
// AuthenticationError is the typed error for CategoryAuthentication.
|
||||||
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||||
// it is intentionally not serialized.
|
// it is intentionally not serialized.
|
||||||
@@ -39,17 +146,150 @@ func (e *AuthenticationError) Unwrap() error {
|
|||||||
return e.Cause
|
return e.Cause
|
||||||
}
|
}
|
||||||
|
|
||||||
// PermissionError is the typed error for CategoryAuthorization.
|
// Error is nil-receiver safe; see ValidationError.Error.
|
||||||
type PermissionError struct {
|
func (e *AuthenticationError) Error() string {
|
||||||
Problem
|
if e == nil {
|
||||||
MissingScopes []string `json:"missing_scopes,omitempty"`
|
return ""
|
||||||
Identity string `json:"identity,omitempty"`
|
}
|
||||||
ConsoleURL string `json:"console_url,omitempty"`
|
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;
|
// Cause preserves an optional wrapped sentinel for errors.Is / errors.Unwrap;
|
||||||
// it is intentionally not serialized.
|
// 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 {
|
type ConfigError struct {
|
||||||
Problem
|
Problem
|
||||||
Field string `json:"field,omitempty"`
|
Field string `json:"field,omitempty"`
|
||||||
@@ -64,15 +304,63 @@ func (e *ConfigError) Unwrap() error {
|
|||||||
return e.Cause
|
return e.Cause
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkError is the typed error for CategoryNetwork.
|
// Error is nil-receiver safe; see ValidationError.Error.
|
||||||
// CauseKind (string) is one of: "timeout" | "tls" | "dns" | "5xx" — the
|
func (e *ConfigError) Error() string {
|
||||||
// canonical wire taxonomy (emitted as JSON key "cause"). Cause preserves an
|
if e == nil {
|
||||||
// optional wrapped sentinel for errors.Is / errors.Unwrap; it is intentionally
|
return ""
|
||||||
// not serialized.
|
}
|
||||||
|
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 {
|
type NetworkError struct {
|
||||||
Problem
|
Problem
|
||||||
CauseKind string `json:"cause,omitempty"`
|
Cause error `json:"-"`
|
||||||
Cause error `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||||
@@ -83,13 +371,112 @@ func (e *NetworkError) Unwrap() error {
|
|||||||
return e.Cause
|
return e.Cause
|
||||||
}
|
}
|
||||||
|
|
||||||
// APIError is the typed error for CategoryAPI (catch-all for classified Lark API
|
// Error is nil-receiver safe; see ValidationError.Error.
|
||||||
// business errors). Detail preserves the raw Lark error map for diagnostics.
|
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 {
|
type APIError struct {
|
||||||
Problem
|
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.
|
// SecurityPolicyError is the typed error for CategoryPolicy security-policy subtypes.
|
||||||
// Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001.
|
// Subtype is "challenge_required" or "access_denied"; Code is 21000 or 21001.
|
||||||
type SecurityPolicyError struct {
|
type SecurityPolicyError struct {
|
||||||
@@ -106,14 +493,125 @@ func (e *SecurityPolicyError) Unwrap() error {
|
|||||||
return e.Cause
|
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.
|
// 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 {
|
type ContentSafetyError struct {
|
||||||
Problem
|
Problem
|
||||||
Rules []string `json:"rules,omitempty"`
|
Rules []string `json:"rules,omitempty"`
|
||||||
|
Cause error `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// InternalError is the typed error for CategoryInternal.
|
// Unwrap is nil-receiver safe; see ValidationError.Unwrap.
|
||||||
// Cause is preserved for logging but not emitted on the wire.
|
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 {
|
type InternalError struct {
|
||||||
Problem
|
Problem
|
||||||
Cause error `json:"-"`
|
Cause error `json:"-"`
|
||||||
@@ -127,10 +625,127 @@ func (e *InternalError) Unwrap() error {
|
|||||||
return e.Cause
|
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.
|
// 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 {
|
type ConfirmationRequiredError struct {
|
||||||
Problem
|
Problem
|
||||||
Risk string `json:"risk"`
|
Risk string `json:"risk"`
|
||||||
Action string `json:"action"`
|
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.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
package errs
|
package errs_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ============================== JSON shape & embed ==============================
|
||||||
|
|
||||||
func TestPermissionErrorJSONShape(t *testing.T) {
|
func TestPermissionErrorJSONShape(t *testing.T) {
|
||||||
perm := &PermissionError{
|
perm := &errs.PermissionError{
|
||||||
Problem: Problem{
|
Problem: errs.Problem{
|
||||||
Category: CategoryAuthorization,
|
Category: errs.CategoryAuthorization,
|
||||||
Subtype: SubtypeMissingScope,
|
Subtype: errs.SubtypeMissingScope,
|
||||||
Message: "x",
|
Message: "x",
|
||||||
},
|
},
|
||||||
MissingScopes: []string{"docx:document"},
|
MissingScopes: []string{"docx:document"},
|
||||||
@@ -53,35 +57,35 @@ func TestPermissionErrorJSONShape(t *testing.T) {
|
|||||||
// PermissionError embeds Problem. ProblemOf works around this by routing
|
// PermissionError embeds Problem. ProblemOf works around this by routing
|
||||||
// via the unexported problemCarrier interface.
|
// via the unexported problemCarrier interface.
|
||||||
func TestEmbedSemanticChasm(t *testing.T) {
|
func TestEmbedSemanticChasm(t *testing.T) {
|
||||||
perm := &PermissionError{
|
perm := &errs.PermissionError{
|
||||||
Problem: Problem{
|
Problem: errs.Problem{
|
||||||
Category: CategoryAuthorization,
|
Category: errs.CategoryAuthorization,
|
||||||
Subtype: SubtypeMissingScope,
|
Subtype: errs.SubtypeMissingScope,
|
||||||
Message: "missing",
|
Message: "missing",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var p *Problem
|
var p *errs.Problem
|
||||||
if errors.As(perm, &p) {
|
if errors.As(perm, &p) {
|
||||||
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
|
t.Errorf("errors.As(*PermissionError, &*Problem) unexpectedly succeeded; Go embed semantic changed")
|
||||||
}
|
}
|
||||||
|
|
||||||
got, ok := ProblemOf(perm)
|
got, ok := errs.ProblemOf(perm)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem")
|
t.Fatalf("ProblemOf(*PermissionError) returned ok=false; expected to extract embedded Problem")
|
||||||
}
|
}
|
||||||
if got != &perm.Problem {
|
if got != &perm.Problem {
|
||||||
t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem)
|
t.Errorf("ProblemOf returned %p, want &perm.Problem = %p", got, &perm.Problem)
|
||||||
}
|
}
|
||||||
if got.Category != CategoryAuthorization {
|
if got.Category != errs.CategoryAuthorization {
|
||||||
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, CategoryAuthorization)
|
t.Errorf("extracted Problem.Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
|
func TestSecurityPolicyErrorUnwrap(t *testing.T) {
|
||||||
orig := errors.New("transport stalled")
|
orig := errors.New("transport stalled")
|
||||||
spe := &SecurityPolicyError{
|
spe := &errs.SecurityPolicyError{
|
||||||
Problem: Problem{Category: CategoryPolicy, Subtype: Subtype("challenge_required"), Message: "blocked"},
|
Problem: errs.Problem{Category: errs.CategoryPolicy, Subtype: errs.Subtype("challenge_required"), Message: "blocked"},
|
||||||
Cause: orig,
|
Cause: orig,
|
||||||
}
|
}
|
||||||
if got := errors.Unwrap(spe); got != orig {
|
if got := errors.Unwrap(spe); got != orig {
|
||||||
@@ -106,12 +110,12 @@ func TestTypedErrors_UnwrapNilReceiver(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
call func() error
|
call func() error
|
||||||
}{
|
}{
|
||||||
{"ValidationError", func() error { var e *ValidationError; return e.Unwrap() }},
|
{"ValidationError", func() error { var e *errs.ValidationError; return e.Unwrap() }},
|
||||||
{"AuthenticationError", func() error { var e *AuthenticationError; return e.Unwrap() }},
|
{"AuthenticationError", func() error { var e *errs.AuthenticationError; return e.Unwrap() }},
|
||||||
{"ConfigError", func() error { var e *ConfigError; return e.Unwrap() }},
|
{"ConfigError", func() error { var e *errs.ConfigError; return e.Unwrap() }},
|
||||||
{"NetworkError", func() error { var e *NetworkError; return e.Unwrap() }},
|
{"NetworkError", func() error { var e *errs.NetworkError; return e.Unwrap() }},
|
||||||
{"SecurityPolicyError", func() error { var e *SecurityPolicyError; return e.Unwrap() }},
|
{"SecurityPolicyError", func() error { var e *errs.SecurityPolicyError; return e.Unwrap() }},
|
||||||
{"InternalError", func() error { var e *InternalError; return e.Unwrap() }},
|
{"InternalError", func() error { var e *errs.InternalError; return e.Unwrap() }},
|
||||||
}
|
}
|
||||||
for _, c := range checks {
|
for _, c := range checks {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
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
|
// TestTypedErrors_UnwrapPropagatesCause pins the positive Unwrap path so the
|
||||||
// nil-safety guard above does not silently drop a real Cause on non-nil
|
// 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
|
// receivers. Without this, a buggy refactor could change `return e.Cause` to
|
||||||
@@ -137,12 +179,12 @@ func TestTypedErrors_UnwrapPropagatesCause(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
err interface{ Unwrap() error }
|
err interface{ Unwrap() error }
|
||||||
}{
|
}{
|
||||||
{"ValidationError", &ValidationError{Cause: cause}},
|
{"ValidationError", &errs.ValidationError{Cause: cause}},
|
||||||
{"AuthenticationError", &AuthenticationError{Cause: cause}},
|
{"AuthenticationError", &errs.AuthenticationError{Cause: cause}},
|
||||||
{"ConfigError", &ConfigError{Cause: cause}},
|
{"ConfigError", &errs.ConfigError{Cause: cause}},
|
||||||
{"NetworkError", &NetworkError{Cause: cause}},
|
{"NetworkError", &errs.NetworkError{Cause: cause}},
|
||||||
{"SecurityPolicyError", &SecurityPolicyError{Cause: cause}},
|
{"SecurityPolicyError", &errs.SecurityPolicyError{Cause: cause}},
|
||||||
{"InternalError", &InternalError{Cause: cause}},
|
{"InternalError", &errs.InternalError{Cause: cause}},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.name, func(t *testing.T) {
|
t.Run(c.name, func(t *testing.T) {
|
||||||
@@ -152,3 +194,387 @@ 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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) |
|
| `Observer` | Before / After each command | No (fire-and-forget audit) |
|
||||||
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
| `Wrap` | Around each command's RunE | Yes (return `*AbortError`) |
|
||||||
| `On(Startup/Shutdown)` | Process lifecycle | N/A |
|
| `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
|
### Plugin lifecycle
|
||||||
|
|
||||||
@@ -102,10 +102,17 @@ the rejected dispatch.
|
|||||||
- A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder
|
- A plugin calling `Restrict()` MUST declare `FailClosed`. The Builder
|
||||||
flips it automatically; the lower-level `Plugin` interface rejects
|
flips it automatically; the lower-level `Plugin` interface rejects
|
||||||
the mismatch with `restricts_mismatch`.
|
the mismatch with `restricts_mismatch`.
|
||||||
- Only ONE plugin per binary can call `Restrict()`. Multi-plugin
|
- A plugin may call `Restrict()` more than once; each call adds one
|
||||||
Restrict is a deliberate `plugin_conflict` error (single-rule
|
scoped Rule and the engine combines them with **OR** — a command is
|
||||||
ecosystem assumption). YAML policy at `~/.lark-cli/policy.yml` is
|
allowed when it satisfies every axis (allow / deny / max_risk /
|
||||||
shadowed by any plugin Restrict.
|
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
|
- The `Wrap` factory runs **once per command dispatch**, not at
|
||||||
install time. Long-lived state (clients, caches, metrics counters)
|
install time. Long-lived state (clients, caches, metrics counters)
|
||||||
must live on the Plugin struct or in package-level variables.
|
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
|
- Commands missing a `risk_level` annotation are denied by default
|
||||||
when a Rule is active. Set `Rule.AllowUnannotated = true` (or
|
when a Rule is active. Set `Rule.AllowUnannotated = true` (or
|
||||||
`allow_unannotated: true` in yaml) to opt out during gradual
|
`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 annotation typos (e.g. `"wrtie"`) are always denied with
|
||||||
`risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated`
|
`risk_invalid` plus a "did you mean" suggestion. `AllowUnannotated`
|
||||||
does NOT bypass this — typo is a code bug, not a missing
|
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 |
|
| `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_hook_registration` | Hook factory returns nil / Wrap chain re-entry / etc. | Yes |
|
||||||
| `invalid_rule` | Rule fails ValidateRule (malformed glob, bad MaxRisk, unknown Identity) | 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 DISTINCT plugins each contributed Restrict (one plugin may contribute several rules) | Yes |
|
||||||
| `multiple_restrict_plugins` | Two or more plugins each contributed Restrict | Yes |
|
|
||||||
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
| `install_failed` | `Plugin.Install` returned a non-nil error | Yes |
|
||||||
| `install_panic` | `Plugin.Install` panicked | 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` |
|
| `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) |
|
| `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` |
|
| `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 |
|
| `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:
|
The `detail.layer` field distinguishes who rejected the call:
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ type Builder struct {
|
|||||||
caps Capabilities
|
caps Capabilities
|
||||||
|
|
||||||
actions []func(Registrar)
|
actions []func(Registrar)
|
||||||
rule *Rule
|
rules []*Rule
|
||||||
|
|
||||||
hookNames map[string]bool
|
hookNames map[string]bool
|
||||||
errs []error
|
errs []error
|
||||||
@@ -125,7 +125,8 @@ func (b *Builder) On(event LifecycleEvent, hookName string, fn LifecycleHandler)
|
|||||||
// sets Restricts=true and FailurePolicy=FailClosed (the framework
|
// sets Restricts=true and FailurePolicy=FailClosed (the framework
|
||||||
// requires both to coexist; the builder enforces the pairing so the
|
// requires both to coexist; the builder enforces the pairing so the
|
||||||
// plugin author cannot accidentally ship a policy plugin under
|
// 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 {
|
func (b *Builder) Restrict(rule *Rule) *Builder {
|
||||||
if rule == nil {
|
if rule == nil {
|
||||||
b.errs = append(b.errs, errors.New("Restrict(nil): rule must not be 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.Restricts = true
|
||||||
b.caps.FailurePolicy = FailClosed
|
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
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +151,7 @@ func (b *Builder) Restrict(rule *Rule) *Builder {
|
|||||||
// The Restrict + FailOpen mismatch is checked here, not in the chained
|
// The Restrict + FailOpen mismatch is checked here, not in the chained
|
||||||
// setters, because the two methods may be called in either order.
|
// setters, because the two methods may be called in either order.
|
||||||
func (b *Builder) Build() (Plugin, error) {
|
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(
|
b.errs = append(b.errs, errors.New(
|
||||||
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
"Restrict() requires FailClosed; do not call FailOpen() after Restrict()"))
|
||||||
}
|
}
|
||||||
@@ -155,7 +163,7 @@ func (b *Builder) Build() (Plugin, error) {
|
|||||||
version: b.version,
|
version: b.version,
|
||||||
caps: b.caps,
|
caps: b.caps,
|
||||||
actions: b.actions,
|
actions: b.actions,
|
||||||
rule: b.rule,
|
rules: b.rules,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,15 +206,15 @@ type builtPlugin struct {
|
|||||||
version string
|
version string
|
||||||
caps Capabilities
|
caps Capabilities
|
||||||
actions []func(Registrar)
|
actions []func(Registrar)
|
||||||
rule *Rule
|
rules []*Rule
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *builtPlugin) Name() string { return p.name }
|
func (p *builtPlugin) Name() string { return p.name }
|
||||||
func (p *builtPlugin) Version() string { return p.version }
|
func (p *builtPlugin) Version() string { return p.version }
|
||||||
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
|
func (p *builtPlugin) Capabilities() Capabilities { return p.caps }
|
||||||
func (p *builtPlugin) Install(r Registrar) error {
|
func (p *builtPlugin) Install(r Registrar) error {
|
||||||
if p.rule != nil {
|
for _, rule := range p.rules {
|
||||||
r.Restrict(p.rule)
|
r.Restrict(rule)
|
||||||
}
|
}
|
||||||
for _, action := range p.actions {
|
for _, action := range p.actions {
|
||||||
action(r)
|
action(r)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ type recorder struct {
|
|||||||
observers int
|
observers int
|
||||||
wrappers int
|
wrappers int
|
||||||
lifecycles 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) {
|
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) Wrap(string, platform.Selector, platform.Wrapper) { r.wrappers++ }
|
||||||
func (r *recorder) On(platform.LifecycleEvent, string, platform.LifecycleHandler) { r.lifecycles++ }
|
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) {
|
func TestBuilder_basicAssembly(t *testing.T) {
|
||||||
p, err := platform.NewPlugin("audit", "0.1.0").
|
p, err := platform.NewPlugin("audit", "0.1.0").
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ package platform
|
|||||||
// identifier is "{plugin}.{hook}". A plugin cannot register two hooks
|
// identifier is "{plugin}.{hook}". A plugin cannot register two hooks
|
||||||
// with the same name in the same Install call.
|
// with the same name in the same Install call.
|
||||||
//
|
//
|
||||||
// Restrict may be called at most once per plugin; multiple plugins
|
// Restrict may be called multiple times per plugin; each call adds one
|
||||||
// contributing Restrict() is a configuration error (the resolver
|
// scoped Rule (OR-combined by the engine). Two or more DISTINCT plugins
|
||||||
// aborts startup).
|
// contributing Restrict() is a configuration error (the resolver aborts
|
||||||
|
// startup).
|
||||||
type Registrar interface {
|
type Registrar interface {
|
||||||
// Observe registers a side-effect-only command hook at the given
|
// Observe registers a side-effect-only command hook at the given
|
||||||
// When stage. The selector decides which commands it fires on.
|
// 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 registers a lifecycle handler for the given event.
|
||||||
On(event LifecycleEvent, hookName string, fn LifecycleHandler)
|
On(event LifecycleEvent, hookName string, fn LifecycleHandler)
|
||||||
|
|
||||||
// Restrict contributes a pruning Rule. The framework merges it
|
// Restrict contributes a pruning Rule. May be called more than once
|
||||||
// with the yaml-sourced Rule using single-rule semantics: plugin
|
// to declare several scoped grants (OR-combined by the engine).
|
||||||
// rule wins, but two plugins both calling Restrict abort startup.
|
// Plugin rules take precedence over the yaml source; two distinct
|
||||||
|
// plugins both calling Restrict abort startup.
|
||||||
Restrict(r *Rule)
|
Restrict(r *Rule)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@
|
|||||||
//go:build authsidecar
|
//go:build authsidecar
|
||||||
|
|
||||||
// Package sidecar provides a transport interceptor for the auth sidecar
|
// Package sidecar provides a transport interceptor for the auth sidecar
|
||||||
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an HTTP URL), all
|
// proxy mode. When LARKSUITE_CLI_AUTH_PROXY is set (an http:// or https://
|
||||||
// outgoing requests are rewritten to the sidecar address. The interceptor
|
// URL), all outgoing requests are rewritten to the sidecar address. The
|
||||||
// strips placeholder credentials, injects proxy headers, and signs each
|
// interceptor strips placeholder credentials, injects proxy headers, and
|
||||||
// request with HMAC-SHA256. No custom DialContext is needed — Go's
|
// signs each request with HMAC-SHA256. No custom DialContext is needed —
|
||||||
// standard http.Transport connects to the sidecar via plain HTTP.
|
// Go's standard http.Transport connects to the sidecar via HTTP, or via
|
||||||
|
// HTTPS (TLS) when the sidecar address is an https:// URL.
|
||||||
package sidecar
|
package sidecar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -46,15 +47,17 @@ func (p *Provider) ResolveInterceptor(ctx context.Context) transport.Interceptor
|
|||||||
}
|
}
|
||||||
key := os.Getenv(envvars.CliProxyKey)
|
key := os.Getenv(envvars.CliProxyKey)
|
||||||
return &Interceptor{
|
return &Interceptor{
|
||||||
key: []byte(key),
|
key: []byte(key),
|
||||||
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
sidecarHost: sidecar.ProxyHost(proxyAddr),
|
||||||
|
sidecarScheme: sidecar.ProxyScheme(proxyAddr),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interceptor rewrites requests for the sidecar proxy.
|
// Interceptor rewrites requests for the sidecar proxy.
|
||||||
type Interceptor struct {
|
type Interceptor struct {
|
||||||
key []byte // HMAC signing key
|
key []byte // HMAC signing key
|
||||||
sidecarHost string // sidecar host:port for URL rewriting
|
sidecarHost string // sidecar host[:port] for URL rewriting
|
||||||
|
sidecarScheme string // "http" (same-host) or "https" (remote TLS sidecar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
// PreRoundTrip rewrites the request for sidecar routing when it carries a
|
||||||
@@ -130,8 +133,13 @@ func (i *Interceptor) PreRoundTrip(req *http.Request) func(resp *http.Response,
|
|||||||
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
req.Header.Set(sidecar.HeaderProxyTimestamp, ts)
|
||||||
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
req.Header.Set(sidecar.HeaderProxySignature, sig)
|
||||||
|
|
||||||
// 5. Rewrite URL to route through sidecar
|
// 5. Rewrite URL to route through sidecar. Scheme follows the configured
|
||||||
req.URL.Scheme = "http"
|
// proxy address: https for a remote (TLS) sidecar, http for a same-host one.
|
||||||
|
scheme := i.sidecarScheme
|
||||||
|
if scheme == "" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
req.URL.Scheme = scheme
|
||||||
req.URL.Host = i.sidecarHost
|
req.URL.Host = i.sidecarHost
|
||||||
|
|
||||||
return nil // no post-hook needed
|
return nil // no post-hook needed
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ package sidecar
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/envvars"
|
||||||
"github.com/larksuite/cli/sidecar"
|
"github.com/larksuite/cli/sidecar"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,6 +99,54 @@ func TestInterceptor_PreRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestInterceptor_PreRoundTrip_HTTPS verifies that a remote (TLS) sidecar
|
||||||
|
// rewrites the request to https://<remote-host>, while still preserving the
|
||||||
|
// original target and signing the request.
|
||||||
|
func TestInterceptor_PreRoundTrip_HTTPS(t *testing.T) {
|
||||||
|
key := []byte("test-key-for-hmac-signing-32byte!")
|
||||||
|
interceptor := &Interceptor{key: key, sidecarHost: "sidecar.mycorp.com", sidecarScheme: "https"}
|
||||||
|
|
||||||
|
req, _ := http.NewRequest("GET", "https://open.feishu.cn/open-apis/im/v1/chats", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+sidecar.SentinelUAT)
|
||||||
|
|
||||||
|
interceptor.PreRoundTrip(req)
|
||||||
|
|
||||||
|
if req.URL.Scheme != "https" {
|
||||||
|
t.Errorf("scheme = %q, want %q", req.URL.Scheme, "https")
|
||||||
|
}
|
||||||
|
if req.URL.Host != "sidecar.mycorp.com" {
|
||||||
|
t.Errorf("host = %q, want %q", req.URL.Host, "sidecar.mycorp.com")
|
||||||
|
}
|
||||||
|
// Original target still preserved for the sidecar to forward upstream.
|
||||||
|
if target := req.Header.Get(sidecar.HeaderProxyTarget); target != "https://open.feishu.cn" {
|
||||||
|
t.Errorf("target = %q, want %q", target, "https://open.feishu.cn")
|
||||||
|
}
|
||||||
|
// Request is still signed.
|
||||||
|
if sig := req.Header.Get(sidecar.HeaderProxySignature); sig == "" {
|
||||||
|
t.Error("signature header should be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveInterceptor_HTTPSScheme pins the end-to-end env→scheme path: a
|
||||||
|
// (mixed-case) https proxy address must produce an interceptor that rewrites to
|
||||||
|
// https, never silently downgrading a remote sidecar to plaintext http.
|
||||||
|
func TestResolveInterceptor_HTTPSScheme(t *testing.T) {
|
||||||
|
t.Setenv(envvars.CliAuthProxy, "HTTPS://sidecar.mycorp.com") // uppercase on purpose
|
||||||
|
t.Setenv(envvars.CliProxyKey, "key")
|
||||||
|
|
||||||
|
ic := (&Provider{}).ResolveInterceptor(context.Background())
|
||||||
|
si, ok := ic.(*Interceptor)
|
||||||
|
if !ok || si == nil {
|
||||||
|
t.Fatalf("expected *Interceptor, got %T", ic)
|
||||||
|
}
|
||||||
|
if si.sidecarScheme != "https" {
|
||||||
|
t.Errorf("sidecarScheme = %q, want %q (uppercase HTTPS must not downgrade)", si.sidecarScheme, "https")
|
||||||
|
}
|
||||||
|
if si.sidecarHost != "sidecar.mycorp.com" {
|
||||||
|
t.Errorf("sidecarHost = %q, want %q", si.sidecarHost, "sidecar.mycorp.com")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInterceptor_BotIdentity(t *testing.T) {
|
func TestInterceptor_BotIdentity(t *testing.T) {
|
||||||
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
interceptor := &Interceptor{key: []byte("key"), sidecarHost: "127.0.0.1:16384"}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/errclass"
|
"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
|
// SecurityPolicyTransport is an http.RoundTripper that intercepts all responses
|
||||||
@@ -28,7 +28,7 @@ func (t *SecurityPolicyTransport) base() http.RoundTripper {
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base
|
return t.Base
|
||||||
}
|
}
|
||||||
return util.FallbackTransport()
|
return transport.Fallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoundTrip implements http.RoundTripper.
|
// RoundTrip implements http.RoundTripper.
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
|
|||||||
}
|
}
|
||||||
var data map[string]interface{}
|
var data map[string]interface{}
|
||||||
if err := json.Unmarshal(body, &data); err != nil {
|
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
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func VerifyUserToken(ctx context.Context, sdk *lark.Client, accessToken string)
|
|||||||
Msg string `json:"msg"`
|
Msg string `json:"msg"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(apiResp.RawBody, &resp); err != nil {
|
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 {
|
if resp.Code != 0 {
|
||||||
return fmt.Errorf("[%d] %s", resp.Code, resp.Msg)
|
return fmt.Errorf("[%d] %s", resp.Code, resp.Msg)
|
||||||
|
|||||||
@@ -5,91 +5,130 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"net"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"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."
|
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
|
// WrapDoAPIError converts SDK-boundary failures into typed errs.* errors:
|
||||||
// actionable API errors for raw `lark-cli api` calls. All other failures
|
// already-typed errors pass through (idempotent), JSON-decode failures
|
||||||
// remain network errors.
|
// become InternalError{SubtypeInvalidResponse}, everything else becomes
|
||||||
//
|
// NetworkError with a chain-derived subtype (timeout / tls / dns /
|
||||||
// Already-classified errors pass through unchanged: any *output.ExitError
|
// server_error / transport-fallback).
|
||||||
// (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.
|
|
||||||
func WrapDoAPIError(err error) error {
|
func WrapDoAPIError(err error) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var existing *output.ExitError
|
|
||||||
if errors.As(err, &existing) {
|
// (1) Pass-through any typed errs.* error.
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, ok := errs.ProblemOf(err); ok {
|
if _, ok := errs.ProblemOf(err); ok {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if isJSONDecodeError(err, false) {
|
|
||||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
// (2) JSON-decode failure at the SDK boundary → InternalError.
|
||||||
fmt.Sprintf("API returned an invalid JSON response: %v", err), rawAPIJSONHint)
|
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
|
// WrapJSONResponseParseError lifts a response-layer JSON parse failure into
|
||||||
// into API errors with hints instead of generic parse failures.
|
// *errs.InternalError{Subtype: SubtypeInvalidResponse}. Empty body, malformed
|
||||||
//
|
// JSON, and mid-stream EOFs all collapse to this single shape.
|
||||||
// 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.
|
|
||||||
func WrapJSONResponseParseError(err error, body []byte) error {
|
func WrapJSONResponseParseError(err error, body []byte) error {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var e *errs.InternalError
|
||||||
if len(bytes.TrimSpace(body)) == 0 {
|
if len(bytes.TrimSpace(body)) == 0 {
|
||||||
return output.ErrWithHint(output.ExitAPI, "api_error",
|
e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an empty JSON response body")
|
||||||
"API returned an empty JSON response body", rawAPIJSONHint)
|
} else {
|
||||||
|
e = errs.NewInternalError(errs.SubtypeInvalidResponse, "API returned an invalid JSON response: %v", err)
|
||||||
}
|
}
|
||||||
if isJSONDecodeError(err, true) {
|
return e.WithHint("%s", rawAPIJSONHint).WithCause(err)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 syntaxErr *json.SyntaxError
|
||||||
var unmarshalTypeErr *json.UnmarshalTypeError
|
var unmarshalTypeErr *json.UnmarshalTypeError
|
||||||
|
|
||||||
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
|
if errors.As(err, &syntaxErr) || errors.As(err, &unmarshalTypeErr) {
|
||||||
return true
|
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()
|
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 true
|
||||||
}
|
}
|
||||||
return strings.Contains(msg, "unexpected end of JSON input") ||
|
lower := strings.ToLower(msg)
|
||||||
strings.Contains(msg, "invalid character") ||
|
return strings.Contains(lower, "invalid character") && strings.Contains(lower, "json")
|
||||||
strings.Contains(msg, "cannot unmarshal")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,173 +4,312 @@
|
|||||||
package client
|
package client
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/x509"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWrapDoAPIError_SyntaxErrorIsAPIDiagnostic(t *testing.T) {
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
err := WrapDoAPIError(&json.SyntaxError{Offset: 1})
|
// WrapDoAPIError: typed error contract.
|
||||||
if err == nil {
|
//
|
||||||
t.Fatal("expected error")
|
// 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
|
// timeoutNetError implements net.Error with Timeout() == true. Used to exercise
|
||||||
if !errors.As(err, &exitErr) {
|
// the timeout branch of the network classifier without depending on a live
|
||||||
t.Fatalf("expected ExitError, got %T", err)
|
// 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 {
|
if ie.Category != errs.CategoryInternal {
|
||||||
t.Fatalf("expected ExitAPI, got %d", exitErr.Code)
|
t.Errorf("Category = %v, want %v", ie.Category, errs.CategoryInternal)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil || !strings.Contains(exitErr.Detail.Message, "invalid JSON response") {
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||||
t.Fatalf("expected JSON diagnostic message, got %#v", exitErr.Detail)
|
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrapJSONResponseParseError_UnexpectedEOFIsAPIDiagnostic(t *testing.T) {
|
// TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError pins the second
|
||||||
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, []byte("{"))
|
// json-decode error variant (type-mismatch decoding) routes through the same
|
||||||
if err == nil {
|
// invalid_response branch — not the network fallback.
|
||||||
t.Fatal("expected error")
|
func TestWrapDoAPIError_UnmarshalTypeError_ReturnsInternalError(t *testing.T) {
|
||||||
|
got := WrapDoAPIError(&json.UnmarshalTypeError{Value: "string", Type: nil})
|
||||||
|
var ie *errs.InternalError
|
||||||
|
if !errors.As(got, &ie) {
|
||||||
|
t.Fatalf("expected *errs.InternalError, got %T", got)
|
||||||
}
|
}
|
||||||
|
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||||
var exitErr *output.ExitError
|
t.Errorf("Subtype = %v, want %v", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic pins branch 1 of
|
// TestWrapDoAPIError_Timeout pins that an SDK transport error whose chain
|
||||||
// the documented 3-branch behaviour: empty (or whitespace-only) response
|
// carries a net.Error with Timeout()==true classifies as
|
||||||
// bodies surface as api_error + rawAPIJSONHint, not network. Pages returning
|
// NetworkError{Subtype: timeout}. Covers the E2E timeout scenario
|
||||||
// only "\n" must not be reclassified as transport failures.
|
// (HTTPS_PROXY pointing at a non-routable address).
|
||||||
func TestWrapJSONResponseParseError_EmptyBodyIsAPIDiagnostic(t *testing.T) {
|
func TestWrapDoAPIError_Timeout(t *testing.T) {
|
||||||
for _, body := range [][]byte{nil, {}, []byte(" \t\n")} {
|
got := WrapDoAPIError(&net.OpError{Op: "dial", Net: "tcp", Err: timeoutNetError{}})
|
||||||
err := WrapJSONResponseParseError(io.ErrUnexpectedEOF, body)
|
var ne *errs.NetworkError
|
||||||
var exitErr *output.ExitError
|
if !errors.As(got, &ne) {
|
||||||
if !errors.As(err, &exitErr) {
|
t.Fatalf("expected *errs.NetworkError, got %T (%v)", got, got)
|
||||||
t.Fatalf("body=%q: expected ExitError, got %T", body, err)
|
}
|
||||||
}
|
if ne.Subtype != errs.SubtypeNetworkTimeout {
|
||||||
if exitErr.Code != output.ExitAPI {
|
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTimeout)
|
||||||
t.Errorf("body=%q: Code = %d, want %d", body, exitErr.Code, output.ExitAPI)
|
}
|
||||||
}
|
if ne.Category != errs.CategoryNetwork {
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
t.Errorf("Category = %v, want %v", ne.Category, errs.CategoryNetwork)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWrapJSONResponseParseError_NonJSONErrorIsNetwork pins branch 3:
|
// TestWrapDoAPIError_TLS pins that an x509.UnknownAuthorityError classifies
|
||||||
// a non-JSON-decode error with a non-empty body falls back to ErrNetwork
|
// as NetworkError{Subtype: tls}.
|
||||||
// (the SDK delivered something but the read itself failed mid-flight).
|
func TestWrapDoAPIError_TLS(t *testing.T) {
|
||||||
func TestWrapJSONResponseParseError_NonJSONErrorIsNetwork(t *testing.T) {
|
got := WrapDoAPIError(&x509.UnknownAuthorityError{})
|
||||||
raw := errors.New("connection reset by peer")
|
var ne *errs.NetworkError
|
||||||
err := WrapJSONResponseParseError(raw, []byte(`{"code":0,"data":{}}`))
|
if !errors.As(got, &ne) {
|
||||||
var exitErr *output.ExitError
|
t.Fatalf("expected *errs.NetworkError, got %T", got)
|
||||||
if !errors.As(err, &exitErr) {
|
|
||||||
t.Fatalf("expected ExitError, got %T", err)
|
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitNetwork {
|
if ne.Subtype != errs.SubtypeNetworkTLS {
|
||||||
t.Errorf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
|
||||||
}
|
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
|
||||||
t.Errorf("Detail.Type = %v, want network", exitErr.Detail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWrapDoAPIError_LegacyExitErrorPassesThrough pins the invariant that an
|
// TestWrapDoAPIError_TLS_HandshakeMessage covers the message-substring fallback
|
||||||
// already-classified *output.ExitError (e.g. output.ErrAuth from
|
// for TLS errors that don't surface as a typed x509 error.
|
||||||
// resolveAccessToken) survives WrapDoAPIError with its category and exit code
|
func TestWrapDoAPIError_TLS_HandshakeMessage(t *testing.T) {
|
||||||
// intact. Without this, missing-token errors regress from exit 3/auth to
|
got := WrapDoAPIError(errors.New("remote error: tls: handshake failure"))
|
||||||
// exit 4/network at the SDK boundary.
|
var ne *errs.NetworkError
|
||||||
func TestWrapDoAPIError_LegacyExitErrorPassesThrough(t *testing.T) {
|
if !errors.As(got, &ne) {
|
||||||
cases := []struct {
|
t.Fatalf("expected *errs.NetworkError, got %T", got)
|
||||||
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"},
|
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
if ne.Subtype != errs.SubtypeNetworkTLS {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Errorf("Subtype = %v, want %v", ne.Subtype, errs.SubtypeNetworkTLS)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWrapDoAPIError_TypedErrsPassesThrough pins that any *errs.* typed error
|
// TestWrapDoAPIError_DNS pins that a *net.DNSError classifies as
|
||||||
// (carries an embedded Problem) passes through unchanged. Forward-compat for
|
// NetworkError{Subtype: dns}.
|
||||||
// stage-4 credential chain migration that will return *errs.AuthenticationError
|
func TestWrapDoAPIError_DNS(t *testing.T) {
|
||||||
// directly instead of legacy output.ErrAuth.
|
got := WrapDoAPIError(&net.DNSError{Name: "example.invalid"})
|
||||||
func TestWrapDoAPIError_TypedErrsPassesThrough(t *testing.T) {
|
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{
|
cases := []error{
|
||||||
&errs.AuthenticationError{Problem: errs.Problem{Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}},
|
&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}},
|
&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}},
|
&errs.NetworkError{Problem: errs.Problem{Category: errs.CategoryNetwork, Subtype: errs.SubtypeNetworkTransport, Message: "transport"}},
|
||||||
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError}},
|
&errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeSDKError, Message: "sdk"}},
|
||||||
}
|
}
|
||||||
for _, in := range cases {
|
for _, in := range cases {
|
||||||
t.Run(fmt.Sprintf("%T", in), func(t *testing.T) {
|
t.Run(fmt.Sprintf("%T", in), func(t *testing.T) {
|
||||||
got := WrapDoAPIError(in)
|
got := WrapDoAPIError(in)
|
||||||
if got != 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
|
// TestWrapDoAPIError_Nil pins that nil in stays nil out (no allocation, no
|
||||||
// error wraps a JSON decode error somewhere in its chain, the outer
|
// panic). Callers rely on this when the SDK returns success.
|
||||||
// classification takes precedence — we never re-classify an already-typed error
|
func TestWrapDoAPIError_Nil(t *testing.T) {
|
||||||
// as a JSON parse error.
|
if got := WrapDoAPIError(nil); got != nil {
|
||||||
func TestWrapDoAPIError_PassthroughBeforeJSONDecode(t *testing.T) {
|
t.Errorf("WrapDoAPIError(nil) = %v, want nil", got)
|
||||||
jsonErr := &json.SyntaxError{Offset: 1}
|
}
|
||||||
authWrappingJSON := fmt.Errorf("%w: wrapped %w", output.ErrAuth("token expired"), jsonErr)
|
}
|
||||||
|
|
||||||
got := WrapDoAPIError(authWrappingJSON)
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// WrapJSONResponseParseError: typed error contract.
|
||||||
var exitErr *output.ExitError
|
//
|
||||||
if !errors.As(got, &exitErr) {
|
// All response-layer parse failures (empty body, malformed JSON, mid-stream
|
||||||
t.Fatalf("expected *output.ExitError, got %T", got)
|
// read failures that surface as parse errors) collapse to a single
|
||||||
}
|
// *errs.InternalError{Subtype: invalid_response}. The rawAPIJSONHint is
|
||||||
if exitErr.Code != output.ExitAuth {
|
// preserved on Problem.Hint so users still get the "may have returned an
|
||||||
t.Fatalf("outer auth classification should win, Code = %d want %d", exitErr.Code, output.ExitAuth)
|
// 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"
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
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/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"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/output"
|
||||||
"github.com/larksuite/cli/internal/util"
|
"github.com/larksuite/cli/internal/util"
|
||||||
)
|
)
|
||||||
@@ -48,16 +52,38 @@ func (c *APIClient) resolveAccessToken(ctx context.Context, as core.Identity) (s
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
var unavailableErr *credential.TokenUnavailableError
|
var unavailableErr *credential.TokenUnavailableError
|
||||||
if errors.As(err, &unavailableErr) {
|
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
|
return "", err
|
||||||
}
|
}
|
||||||
if result.Token == "" {
|
if result.Token == "" {
|
||||||
return "", output.ErrAuth("no access token available for %s", as)
|
return "", newTokenMissingError(as, nil)
|
||||||
}
|
}
|
||||||
return result.Token, 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
|
// buildApiReq converts a RawApiRequest into SDK types and collects
|
||||||
// request-specific options (ExtraOpts, URL-based headers).
|
// request-specific options (ExtraOpts, URL-based headers).
|
||||||
// Auth is handled separately by DoSDKRequest.
|
// 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).
|
// and shortcut RuntimeContext.DoAPI (direct larkcore.ApiReq calls).
|
||||||
//
|
//
|
||||||
// SDK Do() failures are normalised through WrapDoAPIError so every caller
|
// SDK Do() failures are normalised through WrapDoAPIError so every caller
|
||||||
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without each
|
// (cmd/api, RuntimeContext, shortcuts) gets the same wire shape without
|
||||||
// one remembering to wrap. In stage 1 that wire shape is still the legacy
|
// each one remembering to wrap. Today that wire shape is still the legacy
|
||||||
// *output.ExitError envelope (network / api_error) — the stage-4 framework
|
// *output.ExitError envelope (network / api_error); future framework-
|
||||||
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
|
// boundary migration flips WrapDoAPIError to typed *errs.NetworkError /
|
||||||
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
|
// *errs.InternalError per the contract in errs/ERROR_CONTRACT.md.
|
||||||
// Errors that arrive already-classified (legacy *output.ExitError from
|
// Errors that arrive already-classified (legacy *output.ExitError from
|
||||||
// resolveAccessToken's missing-credential paths, or a typed *errs.* from
|
// resolveAccessToken's missing-credential paths, or a typed *errs.*) flow
|
||||||
// future stages) flow through unchanged.
|
// through unchanged.
|
||||||
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
func (c *APIClient) DoSDKRequest(ctx context.Context, req *larkcore.ApiReq, as core.Identity, extraOpts ...larkcore.RequestOptionFunc) (*larkcore.ApiResp, error) {
|
||||||
var opts []larkcore.RequestOptionFunc
|
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)
|
httpReq, err := http.NewRequestWithContext(requestCtx, req.HttpMethod, requestURL, bodyReader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
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
|
// 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)
|
resp, err := httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
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}
|
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()
|
defer resp.Body.Close()
|
||||||
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
msg := strings.TrimSpace(string(errBody))
|
msg := strings.TrimSpace(string(errBody))
|
||||||
if msg != "" {
|
subtype := errs.SubtypeNetworkTransport
|
||||||
err := output.ErrNetwork("HTTP %d: %s", resp.StatusCode, msg)
|
if resp.StatusCode >= 500 {
|
||||||
attachStreamLogID(err, resp.Header)
|
subtype = errs.SubtypeNetworkServer
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
err := output.ErrNetwork("HTTP %d", resp.StatusCode)
|
var netErr *errs.NetworkError
|
||||||
attachStreamLogID(err, resp.Header)
|
if msg != "" {
|
||||||
return nil, err
|
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
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func attachStreamLogID(err *output.ExitError, header http.Header) {
|
func streamLogID(header http.Header) string {
|
||||||
if err == nil || err.Detail == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
|
logID := strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyLogId))
|
||||||
if logID == "" {
|
if logID == "" {
|
||||||
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
|
logID = strings.TrimSpace(header.Get(larkcore.HttpHeaderKeyRequestId))
|
||||||
}
|
}
|
||||||
if logID == "" {
|
return logID
|
||||||
return
|
|
||||||
}
|
|
||||||
err.Detail.Detail = map[string]any{"log_id": logID}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type cancelOnCloseBody struct {
|
type cancelOnCloseBody struct {
|
||||||
@@ -256,10 +283,10 @@ func buildStreamURL(brand core.LarkBrand, req *larkcore.ApiReq) (string, error)
|
|||||||
pathKey := strings.TrimPrefix(segment, ":")
|
pathKey := strings.TrimPrefix(segment, ":")
|
||||||
pathValue, ok := req.PathParams[pathKey]
|
pathValue, ok := req.PathParams[pathKey]
|
||||||
if !ok {
|
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 == "" {
|
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))
|
pathSegs = append(pathSegs, url.PathEscape(pathValue))
|
||||||
}
|
}
|
||||||
@@ -285,7 +312,7 @@ func buildStreamBody(body interface{}) (io.Reader, string, error) {
|
|||||||
default:
|
default:
|
||||||
payload, err := json.Marshal(typed)
|
payload, err := json.Marshal(typed)
|
||||||
if err != nil {
|
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
|
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
|
// JSON parse failures are wrapped via WrapJSONResponseParseError so callers
|
||||||
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
|
// (notably the pagination loop and --page-all paths in cmd/api / cmd/service)
|
||||||
// see an *output.ExitError envelope (api_error for malformed JSON, network
|
// 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
|
// or malformed page body would surface to the root handler as a plain-text
|
||||||
// "Error: ..." line, bypassing the JSON stderr envelope contract. Stage-4
|
// "Error: ..." line and bypass the JSON stderr envelope contract.
|
||||||
// framework-boundary migration will flip this wrapper to typed
|
|
||||||
// *errs.InternalError / *errs.NetworkError.
|
|
||||||
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
func (c *APIClient) CallAPI(ctx context.Context, request RawApiRequest) (interface{}, error) {
|
||||||
resp, err := c.DoAPI(ctx, request)
|
resp, err := c.DoAPI(ctx, request)
|
||||||
if err != nil {
|
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
|
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).
|
// 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
|
||||||
// Deprecated: legacy *output.ExitError wire shape via output.ErrAPI /
|
// the canonical Category/Subtype + identity-aware extension fields (MissingScopes,
|
||||||
// ClassifyLarkError (type "api_error" / "permission" / etc). Preserved so
|
// ConsoleURL, etc.) for known Lark codes; unknown codes still surface as
|
||||||
// existing callers keep emitting the same envelope until per-domain
|
// *errs.APIError{Subtype: unknown}.
|
||||||
// migration to typed errors. The identity parameter is reserved for the
|
|
||||||
// stage-2 typed path; stage-1 ignores it.
|
|
||||||
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
|
func (c *APIClient) CheckResponse(result interface{}, identity core.Identity) error {
|
||||||
resultMap, ok := result.(map[string]interface{})
|
resultMap, ok := result.(map[string]interface{})
|
||||||
if !ok || resultMap == nil {
|
if !ok || resultMap == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
code, _ := util.ToFloat64(resultMap["code"])
|
if code, _ := util.ToFloat64(resultMap["code"]); code == 0 {
|
||||||
if code == 0 {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
larkCode := int(code)
|
cc := errclass.ClassifyContext{Identity: string(identity)}
|
||||||
msg, _ := resultMap["msg"].(string)
|
if c != nil && c.Config != nil {
|
||||||
return output.ErrAPI(larkCode, fmt.Sprintf("API error: [%d] %s", larkCode, msg), resultMap["error"])
|
cc.Brand = string(c.Config.Brand)
|
||||||
|
cc.AppID = c.Config.AppID
|
||||||
|
}
|
||||||
|
return errclass.BuildAPIError(resultMap, cc)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,6 +19,8 @@ import (
|
|||||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
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/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"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
|
// failingTokenResolver always returns TokenUnavailableError, exercising the
|
||||||
// auth/credential failure path through resolveAccessToken.
|
// auth/credential failure path through resolveAccessToken.
|
||||||
type failingTokenResolver struct{}
|
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}
|
return nil, &credential.TokenUnavailableError{Source: "test", Type: spec.Type}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDoSDKRequest_AuthFailurePreservesAuthCategory pins the end-to-end
|
// TestResolveAccessToken_NoToken_ReturnsTypedAuthenticationError pins that
|
||||||
// invariant codex caught the day this PR landed: when resolveAccessToken
|
// the missing-token path of resolveAccessToken returns the typed
|
||||||
// produces output.ErrAuth ("no access token available for <identity>"),
|
// *errs.AuthenticationError{Subtype: TokenMissing} rather than the legacy
|
||||||
// DoSDKRequest must surface it with the original auth classification —
|
// *output.ExitError envelope.
|
||||||
// not silently downgrade it to a network error via the SDK-failure wrap.
|
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
|
// Regression scenario: shortcut path
|
||||||
// (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user
|
// (shortcuts/common/runner.go DoAPI → DoSDKRequest) calling against a user
|
||||||
// identity with no cached token. Pre-fix this surfaced as exit 4/type=network
|
// 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".
|
// 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{
|
ac := &APIClient{
|
||||||
HTTP: &http.Client{},
|
HTTP: &http.Client{},
|
||||||
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
Credential: credential.NewCredentialProvider(nil, nil, &failingTokenResolver{}, nil),
|
||||||
@@ -461,22 +573,20 @@ func TestDoSDKRequest_AuthFailurePreservesAuthCategory(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected auth error, got nil")
|
t.Fatal("expected auth error, got nil")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var authErr *errs.AuthenticationError
|
||||||
if !errors.As(err, &exitErr) {
|
if !errors.As(err, &authErr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
t.Fatalf("expected *errs.AuthenticationError, got %T (%v) — WrapDoAPIError must pass typed *errs.* through unchanged", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAuth {
|
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||||
t.Fatalf("Code = %d, want %d (auth) — confirms ErrAuth was downgraded to network at SDK wrap", exitErr.Code, output.ExitAuth)
|
t.Errorf("Subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||||
}
|
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "auth" {
|
|
||||||
t.Fatalf("Detail.Type = %v, want auth", exitErr.Detail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDoSDKRequest_TransportFailureWrapsAsNetwork pins that genuinely untyped
|
// 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
|
// 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) {
|
func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
||||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||||
return nil, io.ErrUnexpectedEOF
|
return nil, io.ErrUnexpectedEOF
|
||||||
@@ -491,25 +601,29 @@ func TestDoSDKRequest_TransportFailureWrapsAsNetwork(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error from broken transport, got nil")
|
t.Fatal("expected error from broken transport, got nil")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var netErr *errs.NetworkError
|
||||||
if !errors.As(err, &exitErr) {
|
if !errors.As(err, &netErr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
t.Fatalf("expected *errs.NetworkError, got %T (%v)", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitNetwork {
|
if netErr.Category != errs.CategoryNetwork {
|
||||||
t.Fatalf("Code = %d, want %d (network)", exitErr.Code, output.ExitNetwork)
|
t.Errorf("Category = %v, want %v", netErr.Category, errs.CategoryNetwork)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "network" {
|
if netErr.Subtype != errs.SubtypeNetworkTransport {
|
||||||
t.Fatalf("Detail.Type = %v, want network", exitErr.Detail)
|
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
|
// TestCallAPI_ParseJSONFailureWrapsAsAPI pins the typed-envelope contract for
|
||||||
// malformed JSON response bodies: WrapJSONResponseParseError emits api_error
|
// malformed JSON response bodies: WrapJSONResponseParseError emits
|
||||||
// (exit 1) with the rawAPIJSONHint, so the pagination / cmd/api / cmd/service
|
// *errs.InternalError{Subtype: invalid_response} with the rawAPIJSONHint
|
||||||
// callers always see a JSON stderr envelope instead of a bare "Error: ..."
|
// preserved on Problem.Hint. Pagination / cmd/api / cmd/service callers see
|
||||||
// line. Stage-4 framework-boundary migration will flip this wrapper to typed
|
// the typed JSON stderr envelope (exit 5/internal) — wire `type` is
|
||||||
// *errs.InternalError; until then this test pins the legacy shape so we do
|
// "internal", not the legacy "api_error".
|
||||||
// not regress envelope coverage.
|
|
||||||
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
||||||
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
rt := roundTripFunc(func(_ *http.Request) (*http.Response, error) {
|
||||||
return &http.Response{
|
return &http.Response{
|
||||||
@@ -529,17 +643,20 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected JSON parse error, got nil")
|
t.Fatal("expected JSON parse error, got nil")
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var intErr *errs.InternalError
|
||||||
if !errors.As(err, &exitErr) {
|
if !errors.As(err, &intErr) {
|
||||||
t.Fatalf("expected *output.ExitError, got %T", err)
|
t.Fatalf("expected *errs.InternalError, got %T (%v)", err, err)
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitAPI {
|
if intErr.Category != errs.CategoryInternal {
|
||||||
t.Fatalf("Code = %d, want %d (api)", exitErr.Code, output.ExitAPI)
|
t.Errorf("Category = %v, want %v", intErr.Category, errs.CategoryInternal)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "api_error" {
|
if intErr.Subtype != errs.SubtypeInvalidResponse {
|
||||||
t.Fatalf("Detail.Type = %v, want api_error", exitErr.Detail)
|
t.Errorf("Subtype = %v, want %v", intErr.Subtype, errs.SubtypeInvalidResponse)
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Hint != rawAPIJSONHint {
|
if intErr.Hint != rawAPIJSONHint {
|
||||||
t.Errorf("Detail.Hint = %q, want rawAPIJSONHint", exitErr.Detail.Hint)
|
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"
|
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/cmdutil"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/httpmock"
|
"github.com/larksuite/cli/internal/httpmock"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
|
func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
|
||||||
@@ -41,12 +41,11 @@ func TestDoStream_HTTPErrorIncludesLogID(t *testing.T) {
|
|||||||
HttpMethod: http.MethodGet,
|
HttpMethod: http.MethodGet,
|
||||||
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
|
ApiPath: "/open-apis/drive/v1/medias/file_token/download",
|
||||||
}, core.AsBot)
|
}, core.AsBot)
|
||||||
var exitErr *output.ExitError
|
var netErr *errs.NetworkError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
|
if !errors.As(err, &netErr) {
|
||||||
t.Fatalf("expected structured error, got %T %v", err, err)
|
t.Fatalf("expected *errs.NetworkError, got %T %v", err, err)
|
||||||
}
|
}
|
||||||
detail, _ := exitErr.Detail.Detail.(map[string]any)
|
if netErr.LogID != "202605270003" {
|
||||||
if detail["log_id"] != "202605270003" {
|
t.Fatalf("LogID = %q, want %q", netErr.LogID, "202605270003")
|
||||||
t.Fatalf("detail=%#v, want log_id", exitErr.Detail.Detail)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/output"
|
"github.com/larksuite/cli/internal/output"
|
||||||
@@ -52,12 +53,10 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
|||||||
}
|
}
|
||||||
check := opts.CheckError
|
check := opts.CheckError
|
||||||
if check == nil {
|
if check == nil {
|
||||||
// Stage 1: default check routes through legacy CheckResponse
|
// Default check routes through BuildAPIError, producing typed
|
||||||
// (output.ErrAPI / ClassifyLarkError). Stage-2+ migration will
|
// *errs.PermissionError / AuthenticationError / etc. A zero-value
|
||||||
// switch this to errclass.BuildAPIError so PermissionError carries
|
// *APIClient is safe here because BuildAPIError gracefully degrades
|
||||||
// MissingScopes / ConsoleURL — at that point a zero-value
|
// identity-aware fields (ConsoleURL etc.) when AppID is empty.
|
||||||
// *APIClient still works because BuildAPIError short-circuits on
|
|
||||||
// empty AppID, gracefully degrading identity-aware fields.
|
|
||||||
check = func(r interface{}, id core.Identity) error {
|
check = func(r interface{}, id core.Identity) error {
|
||||||
return (&APIClient{}).CheckResponse(r, id)
|
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
|
// Non-JSON error responses (e.g. 404 text/plain from gateway): return error directly
|
||||||
// instead of falling through to the binary-save path.
|
// 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 != "" {
|
if resp.StatusCode >= 400 && !IsJSONContentType(ct) && ct != "" {
|
||||||
body := util.TruncateStrWithEllipsis(strings.TrimSpace(string(resp.RawBody)), 500)
|
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.
|
// 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.
|
// Non-JSON (binary) responses.
|
||||||
if opts.JqExpr != "" {
|
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 != "" {
|
if opts.OutputPath != "" {
|
||||||
return saveAndPrint(opts.FileIO, resp, opts.OutputPath, opts.Out)
|
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.
|
// No --output: auto-save with derived filename.
|
||||||
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
|
meta, err := SaveResponse(opts.FileIO, resp, ResolveFilename(resp))
|
||||||
if err != nil {
|
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)
|
fmt.Fprintf(opts.ErrOut, "binary response detected (Content-Type: %s), saved to file\n", ct)
|
||||||
output.PrintJson(opts.Out, meta)
|
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 {
|
func saveAndPrint(fio fileio.FileIO, resp *larkcore.ApiResp, path string, w io.Writer) error {
|
||||||
meta, err := SaveResponse(fio, resp, path)
|
meta, err := SaveResponse(fio, resp, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return output.Errorf(output.ExitInternal, "file_error", "%s", err)
|
return classifySaveErr(err)
|
||||||
}
|
}
|
||||||
output.PrintJson(w, meta)
|
output.PrintJson(w, meta)
|
||||||
return nil
|
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 ──
|
// ── JSON helpers ──
|
||||||
|
|
||||||
// IsJSONContentType reports whether the Content-Type header indicates a JSON response.
|
// 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
|
var we *fileio.WriteError
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, fileio.ErrPathValidation):
|
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):
|
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):
|
case errors.As(err, &we):
|
||||||
return nil, fmt.Errorf("cannot write file: %s", err)
|
return nil, fmt.Errorf("cannot write file: %w", err)
|
||||||
default:
|
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"
|
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"
|
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/output"
|
||||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
"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") {
|
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)
|
t.Errorf("expected 'HTTP 404: 404 page not found', got: %s", got)
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var apiErr *errs.APIError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitAPI {
|
if !errors.As(err, &apiErr) {
|
||||||
t.Errorf("expected ExitAPI (%d) for 4xx, got code: %d", output.ExitAPI, exitErr.Code)
|
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") {
|
if !strings.Contains(got, "HTTP 502") || !strings.Contains(got, "Bad Gateway") {
|
||||||
t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got)
|
t.Errorf("expected 'HTTP 502' and 'Bad Gateway' in error, got: %s", got)
|
||||||
}
|
}
|
||||||
var exitErr *output.ExitError
|
var netErr *errs.NetworkError
|
||||||
if !errors.As(err, &exitErr) || exitErr.Code != output.ExitNetwork {
|
if !errors.As(err, &netErr) {
|
||||||
t.Errorf("expected ExitNetwork (%d) for 5xx, got code: %d", output.ExitNetwork, exitErr.Code)
|
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?".
|
// it hide?".
|
||||||
//
|
//
|
||||||
// Set once at bootstrap time; consumed read-only thereafter.
|
// 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 {
|
type ActivePolicy struct {
|
||||||
Rule *platform.Rule
|
Rules []*platform.Rule
|
||||||
Source ResolveSource
|
Source ResolveSource
|
||||||
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
DeniedPaths int // number of commands the engine marked as denied (post-aggregation)
|
||||||
}
|
}
|
||||||
@@ -56,20 +60,26 @@ func GetActive() *ActivePolicy {
|
|||||||
return cloneActivePolicy(activePolicy)
|
return cloneActivePolicy(activePolicy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cloneActivePolicy deep-copies the top-level struct plus the embedded
|
// cloneActivePolicy deep-copies the top-level struct, the Rules slice, and
|
||||||
// Rule's slice fields. Other fields (Source, DeniedPaths) are value
|
// each Rule's own slice fields. Other fields (Source, DeniedPaths) are
|
||||||
// types so the struct copy already disjoints them.
|
// value types so the struct copy already disjoints them.
|
||||||
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
func cloneActivePolicy(in *ActivePolicy) *ActivePolicy {
|
||||||
if in == nil {
|
if in == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cp := *in
|
cp := *in
|
||||||
if in.Rule != nil {
|
if in.Rules != nil {
|
||||||
rule := *in.Rule
|
cp.Rules = make([]*platform.Rule, len(in.Rules))
|
||||||
rule.Allow = append([]string(nil), in.Rule.Allow...)
|
for i, r := range in.Rules {
|
||||||
rule.Deny = append([]string(nil), in.Rule.Deny...)
|
if r == nil {
|
||||||
rule.Identities = append([]platform.Identity(nil), in.Rule.Identities...)
|
continue
|
||||||
cp.Rule = &rule
|
}
|
||||||
|
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
|
return &cp
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ package cmdpolicy
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/bmatcuk/doublestar/v4"
|
"github.com/bmatcuk/doublestar/v4"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -36,16 +37,45 @@ type Decision struct {
|
|||||||
Reason string // human-readable
|
Reason string // human-readable
|
||||||
}
|
}
|
||||||
|
|
||||||
// Engine evaluates a Rule against the command tree. It is stateless except
|
// Engine evaluates a set of Rules against the command tree with OR
|
||||||
// for the Rule snapshot it was constructed with.
|
// 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 {
|
type Engine struct {
|
||||||
rule *platform.Rule
|
rules []*platform.Rule
|
||||||
}
|
}
|
||||||
|
|
||||||
// New returns an Engine bound to a Rule. A nil Rule means "no user-layer
|
// New returns an Engine bound to a single Rule. A nil Rule means "no
|
||||||
// restriction" -- EvaluateOne always returns Allowed=true.
|
// 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 {
|
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**
|
// 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
|
// 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 {
|
func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
||||||
if e.rule == nil {
|
if len(e.rules) == 0 {
|
||||||
return Decision{Allowed: true}
|
return Decision{Allowed: true}
|
||||||
}
|
}
|
||||||
r := e.rule
|
|
||||||
path := CanonicalPath(cmd)
|
path := CanonicalPath(cmd)
|
||||||
|
|
||||||
if IsDiagnosticPath(path) {
|
if IsDiagnosticPath(path) {
|
||||||
return Decision{Allowed: true}
|
return Decision{Allowed: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A registered Rule expresses intent over the closed risk taxonomy
|
// risk_invalid is a property of the COMMAND's own annotation (the
|
||||||
// (read / write / high-risk-write). Two ways a command can fall
|
// annotation exists but is a typo / not in the closed taxonomy
|
||||||
// outside that 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,
|
// The "absent" case (no risk_level annotation at all) is per-rule:
|
||||||
// but Rule.AllowUnannotated=true opts out for gradual adoption.
|
// each rule's AllowUnannotated decides, so it lives inside evalRule.
|
||||||
// - "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.
|
|
||||||
cmdRiskStr, hasRisk := cmdmeta.Risk(cmd)
|
cmdRiskStr, hasRisk := cmdmeta.Risk(cmd)
|
||||||
cmdRisk := platform.Risk(cmdRiskStr)
|
cmdRisk := platform.Risk(cmdRiskStr)
|
||||||
var (
|
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)),
|
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{
|
return Decision{
|
||||||
Allowed: false,
|
Allowed: false,
|
||||||
ReasonCode: "risk_not_annotated",
|
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 {
|
if matched, ok := firstMatch(r.Deny, path); ok {
|
||||||
return Decision{
|
return Decision{
|
||||||
Allowed: false,
|
Allowed: false,
|
||||||
@@ -171,6 +229,34 @@ func (e *Engine) EvaluateOne(cmd *cobra.Command) Decision {
|
|||||||
return Decision{Allowed: true}
|
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
|
// BuildDeniedByPath converts engine Decisions to a deniedByPath map keyed
|
||||||
// by canonical path. It performs the parent-group aggregation defined in
|
// by canonical path. It performs the parent-group aggregation defined in
|
||||||
// the tech doc: a non-runnable parent whose every runnable descendant is
|
// 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
|
// Apply must install denyStubs only on Layer="policy" entries. A
|
||||||
// "strict_mode" denial in the same map must be left for
|
// "strict_mode" denial in the same map must be left for
|
||||||
// applyStrictModeDenials in cmd/.
|
// applyStrictModeDenials in cmd/.
|
||||||
|
|||||||
@@ -33,44 +33,69 @@ type PluginRule struct {
|
|||||||
|
|
||||||
type Sources struct {
|
type Sources struct {
|
||||||
PluginRules []PluginRule
|
PluginRules []PluginRule
|
||||||
YAMLRule *platform.Rule
|
YAMLRules []*platform.Rule
|
||||||
YAMLPath string
|
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
|
// Resolve picks by precedence: plugin > yaml > none, returning the full
|
||||||
// yaml via LoadYAMLPolicy first. Winner is validated.
|
// rule set the winning source contributes. Pure function; load yaml via
|
||||||
func Resolve(s Sources) (*platform.Rule, ResolveSource, error) {
|
// LoadYAMLPolicy first. Every returned rule is validated.
|
||||||
if len(s.PluginRules) > 1 {
|
//
|
||||||
names := make([]string, len(s.PluginRules))
|
// Multi-rule semantics (single owner): one plugin may contribute several
|
||||||
for i, pr := range s.PluginRules {
|
// rules (each a scoped grant, OR-combined by the engine), but two or more
|
||||||
names[i] = pr.PluginName
|
// DISTINCT plugins contributing rules is still a configuration error --
|
||||||
}
|
// the resolver aborts so independent plugins cannot silently widen each
|
||||||
return nil, ResolveSource{}, fmt.Errorf("%w: %v", ErrMultipleRestricts, names)
|
// 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 {
|
if len(s.PluginRules) > 0 {
|
||||||
pr := s.PluginRules[0]
|
rules := make([]*platform.Rule, 0, len(s.PluginRules))
|
||||||
if err := ValidateRule(pr.Rule); err != nil {
|
for _, pr := range s.PluginRules {
|
||||||
return nil, ResolveSource{}, fmt.Errorf("plugin %q rule invalid: %w", pr.PluginName, err)
|
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 len(s.YAMLRules) > 0 {
|
||||||
if err := ValidateRule(s.YAMLRule); err != nil {
|
for _, r := range s.YAMLRules {
|
||||||
return nil, ResolveSource{}, fmt.Errorf("policy yaml %q: %w", s.YAMLPath, err)
|
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
|
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,
|
// LoadYAMLPolicy returns (nil, nil) when path is empty or file is absent,
|
||||||
// so callers can pass the result straight into Sources.YAMLRule.
|
// so callers can pass the result straight into Sources.YAMLRules. A
|
||||||
func LoadYAMLPolicy(path string) (*platform.Rule, error) {
|
// present file yields one or more rules (see yaml.Parse).
|
||||||
|
func LoadYAMLPolicy(path string) ([]*platform.Rule, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -84,9 +109,9 @@ func LoadYAMLPolicy(path string) (*platform.Rule, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read policy yaml %q: %w", path, err)
|
return nil, fmt.Errorf("read policy yaml %q: %w", path, err)
|
||||||
}
|
}
|
||||||
rule, err := pyaml.Parse(data)
|
rules, err := pyaml.Parse(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("policy yaml %q: %w", path, err)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("Resolve err: %v", err)
|
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)
|
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) {
|
func TestResolve_pluginShadowsYaml(t *testing.T) {
|
||||||
pluginRule := &platform.Rule{Name: "from-plugin"}
|
pluginRule := &platform.Rule{Name: "from-plugin"}
|
||||||
yamlRule := &platform.Rule{Name: "from-yaml"}
|
yamlRule := &platform.Rule{Name: "from-yaml"}
|
||||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
|
PluginRules: []cmdpolicy.PluginRule{{PluginName: "secaudit", Rule: pluginRule}},
|
||||||
YAMLRule: yamlRule,
|
YAMLRules: []*platform.Rule{yamlRule},
|
||||||
YAMLPath: "/some/policy.yml",
|
YAMLPath: "/some/policy.yml",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve err: %v", err)
|
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)
|
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) {
|
func TestResolve_yamlWhenNoPlugin(t *testing.T) {
|
||||||
yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"}
|
yamlRule := &platform.Rule{Name: "from-yaml", MaxRisk: "read"}
|
||||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
YAMLRule: yamlRule,
|
YAMLRules: []*platform.Rule{yamlRule},
|
||||||
YAMLPath: "/some/policy.yml",
|
YAMLPath: "/some/policy.yml",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve err: %v", err)
|
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)
|
t.Fatalf("yaml should win when no plugin, got %+v / %+v", got, src)
|
||||||
}
|
}
|
||||||
if src.Name != "/some/policy.yml" {
|
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) {
|
func TestResolve_emptyEverythingIsNone(t *testing.T) {
|
||||||
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{})
|
got, src, err := cmdpolicy.Resolve(cmdpolicy.Sources{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve err: %v", err)
|
t.Fatalf("Resolve err: %v", err)
|
||||||
}
|
}
|
||||||
if got != nil || src.Kind != cmdpolicy.SourceNone {
|
if len(got) != 0 || src.Kind != cmdpolicy.SourceNone {
|
||||||
t.Fatalf("expected (nil, SourceNone), got (%v, %+v)", got, src)
|
t.Fatalf("expected (empty, SourceNone), got (%v, %+v)", got, src)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two plugins both contributing a Rule must produce the typed error so
|
// Two DISTINCT plugins both contributing a Rule must produce the typed
|
||||||
// the bootstrap pipeline aborts (hard-constraint #7).
|
// error so the bootstrap pipeline aborts (single-owner invariant): one
|
||||||
func TestResolve_multipleRestrictIsError(t *testing.T) {
|
// plugin cannot silently widen another plugin's policy.
|
||||||
|
func TestResolve_multipleRestrictPluginsIsError(t *testing.T) {
|
||||||
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
_, _, err := cmdpolicy.Resolve(cmdpolicy.Sources{
|
||||||
PluginRules: []cmdpolicy.PluginRule{
|
PluginRules: []cmdpolicy.PluginRule{
|
||||||
{PluginName: "a", Rule: &platform.Rule{Name: "a"}},
|
{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
|
// 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.
|
// casing not-exist.
|
||||||
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
|
func TestLoadYAMLPolicy_missingIsSilent(t *testing.T) {
|
||||||
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
|
missing := filepath.Join(t.TempDir(), "absent-policy.yml")
|
||||||
rule, err := cmdpolicy.LoadYAMLPolicy(missing)
|
rules, err := cmdpolicy.LoadYAMLPolicy(missing)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("missing yaml should not error, got %v", err)
|
t.Fatalf("missing yaml should not error, got %v", err)
|
||||||
}
|
}
|
||||||
if rule != nil {
|
if rules != nil {
|
||||||
t.Fatalf("missing yaml should return nil rule, got %+v", rule)
|
t.Fatalf("missing yaml should return nil rules, got %+v", rules)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
|
func TestLoadYAMLPolicy_emptyPathIsNoop(t *testing.T) {
|
||||||
rule, err := cmdpolicy.LoadYAMLPolicy("")
|
rules, err := cmdpolicy.LoadYAMLPolicy("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("empty path should not error, got %v", err)
|
t.Fatalf("empty path should not error, got %v", err)
|
||||||
}
|
}
|
||||||
if rule != nil {
|
if rules != nil {
|
||||||
t.Fatalf("empty path should return nil rule, got %+v", rule)
|
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 {
|
if err := os.WriteFile(yamlPath, []byte("name: from-yaml\nmax_risk: read\n"), 0o644); err != nil {
|
||||||
t.Fatalf("write yaml: %v", err)
|
t.Fatalf("write yaml: %v", err)
|
||||||
}
|
}
|
||||||
rule, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
rules, err := cmdpolicy.LoadYAMLPolicy(yamlPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadYAMLPolicy err: %v", err)
|
t.Fatalf("LoadYAMLPolicy err: %v", err)
|
||||||
}
|
}
|
||||||
if rule == nil || rule.Name != "from-yaml" {
|
if len(rules) != 1 || rules[0].Name != "from-yaml" {
|
||||||
t.Fatalf("expected rule with name=from-yaml, got %+v", rule)
|
t.Fatalf("expected one rule with name=from-yaml, got %+v", rules)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Package yaml parses a Rule from yaml bytes. It is kept separate from the
|
// Package yaml parses one or more Rules from yaml bytes. It is kept
|
||||||
// public extension/platform package so that platform stays free of yaml
|
// separate from the public extension/platform package so that platform
|
||||||
// library dependencies -- plugins constructing a Rule in Go code never
|
// stays free of yaml library dependencies -- plugins constructing a Rule
|
||||||
// import yaml, only the file loader does.
|
// in Go code never import yaml, only the file loader does.
|
||||||
//
|
//
|
||||||
// This package does **structural** parsing only (yaml syntax + unknown-field
|
// This package does **structural** parsing only (yaml syntax + unknown-field
|
||||||
// rejection). Semantic validation (valid MaxRisk enum, valid identity
|
// rejection). Semantic validation (valid MaxRisk enum, valid identity
|
||||||
@@ -23,9 +23,9 @@ import (
|
|||||||
"github.com/larksuite/cli/extension/platform"
|
"github.com/larksuite/cli/extension/platform"
|
||||||
)
|
)
|
||||||
|
|
||||||
// schema is the internal yaml-tagged shape. Mirrors platform.Rule but lives
|
// ruleSchema is the internal yaml-tagged shape of one rule. Mirrors
|
||||||
// here so the public Rule has no yaml tag baggage.
|
// platform.Rule but lives here so the public Rule has no yaml tag baggage.
|
||||||
type schema struct {
|
type ruleSchema struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Description string `yaml:"description,omitempty"`
|
Description string `yaml:"description,omitempty"`
|
||||||
Allow []string `yaml:"allow,omitempty"`
|
Allow []string `yaml:"allow,omitempty"`
|
||||||
@@ -35,35 +35,45 @@ type schema struct {
|
|||||||
AllowUnannotated bool `yaml:"allow_unannotated,omitempty"`
|
AllowUnannotated bool `yaml:"allow_unannotated,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse decodes yaml bytes into a *platform.Rule. Unknown fields are
|
// fileSchema is the top-level document shape. Two mutually-exclusive
|
||||||
// rejected so an old binary cannot silently ignore new schema additions
|
// layouts are accepted:
|
||||||
// (forward-compat safeguard).
|
|
||||||
//
|
//
|
||||||
// Semantic validation (MaxRisk taxonomy, identity values, glob syntax) is
|
// - a single rule written with flat top-level fields (the historical
|
||||||
// the caller's responsibility -- run the result through
|
// layout; the inlined ruleSchema), or
|
||||||
// internal/cmdpolicy.ValidateRule before handing it to the engine.
|
// - a "rules:" list of rule objects (multi-rule layout).
|
||||||
func Parse(data []byte) (*platform.Rule, error) {
|
//
|
||||||
var s schema
|
// Mixing the two (flat fields AND a rules: list in the same file) is a
|
||||||
dec := gopkgyaml.NewDecoder(bytesReader(data))
|
// configuration error -- Parse rejects it rather than guessing intent.
|
||||||
dec.KnownFields(true)
|
//
|
||||||
if err := dec.Decode(&s); err != nil {
|
// Rules is a pointer so Parse can tell "rules: key absent" (nil) apart
|
||||||
return nil, fmt.Errorf("parse policy yaml: %w", err)
|
// 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
|
// isZero reports whether every field is its zero value. Used to detect
|
||||||
// per call, so a stray "---" followed by another document would
|
// the flat-fields-plus-rules: mixing error.
|
||||||
// silently drop the trailing rule.
|
func (s ruleSchema) isZero() bool {
|
||||||
var extra schema
|
return s.Name == "" && s.Description == "" &&
|
||||||
if err := dec.Decode(&extra); !errors.Is(err, io.EOF) {
|
len(s.Allow) == 0 && len(s.Deny) == 0 &&
|
||||||
if err == nil {
|
s.MaxRisk == "" && len(s.Identities) == 0 && !s.AllowUnannotated
|
||||||
return nil, fmt.Errorf("parse policy yaml: multiple YAML documents are not allowed")
|
}
|
||||||
|
|
||||||
|
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{
|
return &platform.Rule{
|
||||||
Name: s.Name,
|
Name: s.Name,
|
||||||
@@ -73,5 +83,53 @@ func Parse(data []byte) (*platform.Rule, error) {
|
|||||||
MaxRisk: platform.Risk(s.MaxRisk),
|
MaxRisk: platform.Risk(s.MaxRisk),
|
||||||
Identities: idents,
|
Identities: idents,
|
||||||
AllowUnannotated: s.AllowUnannotated,
|
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:
|
identities:
|
||||||
- user
|
- user
|
||||||
`)
|
`)
|
||||||
rule, err := pyaml.Parse(data)
|
rules, err := pyaml.Parse(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse failed: %v", err)
|
t.Fatalf("Parse failed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -36,8 +36,59 @@ identities:
|
|||||||
MaxRisk: "read",
|
MaxRisk: "read",
|
||||||
Identities: []platform.Identity{"user"},
|
Identities: []platform.Identity{"user"},
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(rule, want) {
|
// A flat top-level rule yields exactly one element (backward compat).
|
||||||
t.Fatalf("rule = %+v, want %+v", rule, want)
|
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
|
max_risk: read
|
||||||
allow_unannotated: true
|
allow_unannotated: true
|
||||||
`)
|
`)
|
||||||
rule, err := pyaml.Parse(data)
|
rules, err := pyaml.Parse(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse failed: %v", err)
|
t.Fatalf("Parse failed: %v", err)
|
||||||
}
|
}
|
||||||
if !rule.AllowUnannotated {
|
if !rules[0].AllowUnannotated {
|
||||||
t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)")
|
t.Fatalf("AllowUnannotated = false, want true (yaml field must propagate)")
|
||||||
}
|
}
|
||||||
if rule.MaxRisk != "read" || rule.Name != "agent-readonly" {
|
if rules[0].MaxRisk != "read" || rules[0].Name != "agent-readonly" {
|
||||||
t.Errorf("other fields lost: %+v", rule)
|
t.Errorf("other fields lost: %+v", rules[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,11 +122,11 @@ func TestParse_allowUnannotatedDefaultsFalse(t *testing.T) {
|
|||||||
name: x
|
name: x
|
||||||
max_risk: read
|
max_risk: read
|
||||||
`)
|
`)
|
||||||
rule, err := pyaml.Parse(data)
|
rules, err := pyaml.Parse(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Parse failed: %v", err)
|
t.Fatalf("Parse failed: %v", err)
|
||||||
}
|
}
|
||||||
if rule.AllowUnannotated {
|
if rules[0].AllowUnannotated {
|
||||||
t.Fatalf("AllowUnannotated must default to false when key is absent")
|
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
|
// structural yaml; an invalid max_risk passes through (validation happens
|
||||||
// downstream).
|
// downstream).
|
||||||
func TestParse_doesNotValidateSemantics(t *testing.T) {
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("structural parse should succeed, got %v", err)
|
t.Fatalf("structural parse should succeed, got %v", err)
|
||||||
}
|
}
|
||||||
if rule.MaxRisk != "nuclear" {
|
if rules[0].MaxRisk != "nuclear" {
|
||||||
t.Fatalf("MaxRisk = %q, want passed through as-is", rule.MaxRisk)
|
t.Fatalf("MaxRisk = %q, want passed through as-is", rules[0].MaxRisk)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ package cmdutil
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -13,13 +12,13 @@ import (
|
|||||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
"github.com/larksuite/cli/internal/client"
|
"github.com/larksuite/cli/internal/client"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/output"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Factory holds shared dependencies injected into every command.
|
// 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, ", ")
|
list := strings.Join(supported, ", ")
|
||||||
if f.IdentityAutoDetected {
|
if f.IdentityAutoDetected {
|
||||||
return output.ErrValidation(
|
base := errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s\nhint: use --as %s",
|
"resolved identity %q (via auto-detect or default-as) is not supported, this command only supports: %s",
|
||||||
as, list, supported[0])
|
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
|
// 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 {
|
func (f *Factory) CheckStrictMode(ctx context.Context, as core.Identity) error {
|
||||||
mode := f.ResolveStrictMode(ctx)
|
mode := f.ResolveStrictMode(ctx)
|
||||||
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
if mode.IsActive() && !mode.AllowsIdentity(as) {
|
||||||
return output.ErrWithHint(output.ExitValidation, "command_denied",
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
fmt.Sprintf("strict mode is %q, only %s-identity commands are available", mode, mode.ForcedIdentity()),
|
"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)")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -202,9 +208,9 @@ func (f *Factory) NewAPIClientWithConfig(cfg *core.CliConfig) (*client.APIClient
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RequireBuiltinCredentialProvider returns a structured error (exit 2, code
|
// RequireBuiltinCredentialProvider returns a typed validation error when an
|
||||||
// "external_provider") when an extension provider is actively managing credentials.
|
// extension provider is actively managing credentials. Intended for use as
|
||||||
// Intended for use as PersistentPreRunE on the auth and config parent commands.
|
// PersistentPreRunE on the auth and config parent commands.
|
||||||
//
|
//
|
||||||
// Returns nil when:
|
// Returns nil when:
|
||||||
// - f.Credential is nil (test environments without credential setup)
|
// - f.Credential is nil (test environments without credential setup)
|
||||||
@@ -220,10 +226,7 @@ func (f *Factory) RequireBuiltinCredentialProvider(ctx context.Context, command
|
|||||||
if provName == "" {
|
if provName == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return output.ErrWithHint(
|
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||||
output.ExitValidation,
|
"%q is not supported: credentials are provided externally and do not support interactive management", command).
|
||||||
"external_provider",
|
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.")
|
||||||
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.",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
"github.com/larksuite/cli/internal/registry"
|
"github.com/larksuite/cli/internal/registry"
|
||||||
_ "github.com/larksuite/cli/internal/security/contentsafety" // register content safety provider
|
_ "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
|
_ "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) {
|
func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) {
|
||||||
return sync.OnceValues(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()
|
var rt http.RoundTripper = transport.Shared()
|
||||||
transport = &RetryTransport{Base: transport}
|
rt = &RetryTransport{Base: rt}
|
||||||
transport = &SecurityHeaderTransport{Base: transport}
|
rt = &SecurityHeaderTransport{Base: rt}
|
||||||
transport = &auth.SecurityPolicyTransport{Base: transport} // Add our global response interceptor
|
rt = &auth.SecurityPolicyTransport{Base: rt} // Add our global response interceptor
|
||||||
transport = wrapWithExtension(transport)
|
rt = wrapWithExtension(rt)
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Transport: transport,
|
Transport: rt,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
CheckRedirect: safeRedirectPolicy,
|
CheckRedirect: safeRedirectPolicy,
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
|||||||
lark.WithLogLevel(larkcore.LogLevelError),
|
lark.WithLogLevel(larkcore.LogLevelError),
|
||||||
lark.WithHeaders(BaseSecurityHeaders()),
|
lark.WithHeaders(BaseSecurityHeaders()),
|
||||||
}
|
}
|
||||||
util.WarnIfProxied(f.IOStreams.ErrOut)
|
transport.WarnIfProxied(f.IOStreams.ErrOut)
|
||||||
opts = append(opts, lark.WithHttpClient(&http.Client{
|
opts = append(opts, lark.WithHttpClient(&http.Client{
|
||||||
Transport: buildSDKTransport(),
|
Transport: buildSDKTransport(),
|
||||||
CheckRedirect: safeRedirectPolicy,
|
CheckRedirect: safeRedirectPolicy,
|
||||||
@@ -141,7 +141,7 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func buildSDKTransport() http.RoundTripper {
|
func buildSDKTransport() http.RoundTripper {
|
||||||
var sdkTransport http.RoundTripper = util.SharedTransport()
|
var sdkTransport http.RoundTripper = transport.Shared()
|
||||||
sdkTransport = &RetryTransport{Base: sdkTransport}
|
sdkTransport = &RetryTransport{Base: sdkTransport}
|
||||||
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
sdkTransport = &UserAgentTransport{Base: sdkTransport}
|
||||||
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
sdkTransport = &BuildHeaderTransport{Base: sdkTransport}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
extcred "github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/credential"
|
"github.com/larksuite/cli/internal/credential"
|
||||||
@@ -179,14 +180,15 @@ func TestCheckIdentity_Unsupported_AutoDetected(t *testing.T) {
|
|||||||
f.IdentityAutoDetected = true
|
f.IdentityAutoDetected = true
|
||||||
|
|
||||||
err := f.CheckIdentity(core.AsUser, []string{"bot"})
|
err := f.CheckIdentity(core.AsUser, []string{"bot"})
|
||||||
if err == nil {
|
var ve *errs.ValidationError
|
||||||
t.Fatal("expected error")
|
if !errors.As(err, &ve) {
|
||||||
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "resolved identity") {
|
if !strings.Contains(ve.Message, "resolved identity") {
|
||||||
t.Errorf("expected 'resolved identity' in error, got: %v", err)
|
t.Errorf("expected 'resolved identity' in message, got: %v", ve.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(err.Error(), "hint: use --as bot") {
|
if !strings.Contains(ve.Hint, "use --as bot") {
|
||||||
t.Errorf("expected hint in error, got: %v", err)
|
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")
|
t.Fatal("expected error, got nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
var exitErr *output.ExitError
|
var ve *errs.ValidationError
|
||||||
if !errors.As(err, &exitErr) {
|
if !errors.As(err, &ve) {
|
||||||
t.Fatalf("error type = %T, want *output.ExitError", err)
|
t.Fatalf("error type = %T, want *errs.ValidationError", err)
|
||||||
}
|
}
|
||||||
if exitErr.Code != output.ExitValidation {
|
if got := output.ExitCodeOf(err); got != output.ExitValidation {
|
||||||
t.Errorf("exit code = %d, want %d", exitErr.Code, output.ExitValidation)
|
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
|
||||||
}
|
}
|
||||||
if exitErr.Detail == nil || exitErr.Detail.Type != "external_provider" {
|
if ve.Message == "" {
|
||||||
t.Errorf("error type field = %v, want %q", exitErr.Detail, "external_provider")
|
|
||||||
}
|
|
||||||
if exitErr.Detail.Message == "" {
|
|
||||||
t.Error("expected non-empty message")
|
t.Error("expected non-empty message")
|
||||||
}
|
}
|
||||||
if exitErr.Detail.Hint == "" {
|
if ve.Hint == "" {
|
||||||
t.Error("expected non-empty hint")
|
t.Error("expected non-empty hint")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const (
|
|||||||
|
|
||||||
officialModulePath = "github.com/larksuite/cli"
|
officialModulePath = "github.com/larksuite/cli"
|
||||||
|
|
||||||
agentTraceMaxLen = 256
|
agentTraceMaxLen = 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
exttransport "github.com/larksuite/cli/extension/transport"
|
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
|
// RetryTransport is an http.RoundTripper that retries on 5xx responses
|
||||||
@@ -24,7 +24,7 @@ func (t *RetryTransport) base() http.RoundTripper {
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base
|
return t.Base
|
||||||
}
|
}
|
||||||
return util.FallbackTransport()
|
return transport.Fallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *RetryTransport) delay() time.Duration {
|
func (t *RetryTransport) delay() time.Duration {
|
||||||
@@ -69,7 +69,7 @@ func (t *UserAgentTransport) RoundTrip(req *http.Request) (*http.Response, error
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base.RoundTrip(req)
|
return t.Base.RoundTrip(req)
|
||||||
}
|
}
|
||||||
return util.FallbackTransport().RoundTrip(req)
|
return transport.Fallback().RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildHeaderTransport is an http.RoundTripper that force-writes the
|
// 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 {
|
if t.Base != nil {
|
||||||
return t.Base.RoundTrip(req)
|
return t.Base.RoundTrip(req)
|
||||||
}
|
}
|
||||||
return util.FallbackTransport().RoundTrip(req)
|
return transport.Fallback().RoundTrip(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
// SecurityHeaderTransport is an http.RoundTripper that injects CLI security
|
||||||
@@ -100,7 +100,7 @@ func (t *SecurityHeaderTransport) base() http.RoundTripper {
|
|||||||
if t.Base != nil {
|
if t.Base != nil {
|
||||||
return t.Base
|
return t.Base
|
||||||
}
|
}
|
||||||
return util.FallbackTransport()
|
return transport.Fallback()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RoundTrip implements http.RoundTripper.
|
// RoundTrip implements http.RoundTripper.
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ func TestBuildHeaderTransport_OverridesEvenWithoutTamper(t *testing.T) {
|
|||||||
|
|
||||||
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
// TestBuildHeaderTransport_NilBase_UsesFallback verifies that when Base is nil,
|
||||||
// the transport still sets X-Cli-Build and routes the request through
|
// 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.
|
// branch in RoundTrip that is otherwise unreachable with a non-nil Base.
|
||||||
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
func TestBuildHeaderTransport_NilBase_UsesFallback(t *testing.T) {
|
||||||
var receivedBuild string
|
var receivedBuild string
|
||||||
|
|||||||
@@ -12,13 +12,44 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/auth"
|
"github.com/larksuite/cli/internal/auth"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/errclass"
|
||||||
"github.com/larksuite/cli/internal/keychain"
|
"github.com/larksuite/cli/internal/keychain"
|
||||||
|
|
||||||
extcred "github.com/larksuite/cli/extension/credential"
|
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.
|
// DefaultAccountProvider resolves account from config.json via keychain.
|
||||||
type DefaultAccountProvider struct {
|
type DefaultAccountProvider struct {
|
||||||
keychain func() keychain.KeychainAccess
|
keychain func() keychain.KeychainAccess
|
||||||
@@ -170,7 +201,7 @@ func (p *DefaultTokenProvider) doResolveTAT(ctx context.Context) (*TokenResult,
|
|||||||
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
return nil, fmt.Errorf("failed to parse TAT response: %w", err)
|
||||||
}
|
}
|
||||||
if result.Code != 0 {
|
if result.Code != 0 {
|
||||||
return nil, fmt.Errorf("TAT API error: [%d] %s", result.Code, result.Msg)
|
return nil, classifyTATResponseCode(result.Code, result.Msg, string(acct.Brand), acct.AppID)
|
||||||
}
|
}
|
||||||
return &TokenResult{Token: result.TenantAccessToken}, nil
|
return &TokenResult{Token: result.TenantAccessToken}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
package credential
|
package credential
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
||||||
@@ -15,3 +18,68 @@ func TestDefaultTokenProvider_Dispatches(t *testing.T) {
|
|||||||
func TestDefaultAccountProvider_Implements(t *testing.T) {
|
func TestDefaultAccountProvider_Implements(t *testing.T) {
|
||||||
var _ DefaultAccountResolver = &DefaultAccountProvider{}
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ const (
|
|||||||
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
CliStrictMode = "LARKSUITE_CLI_STRICT_MODE"
|
||||||
|
|
||||||
// Sidecar proxy (auth proxy mode)
|
// Sidecar proxy (auth proxy mode)
|
||||||
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar HTTP address, e.g. "http://127.0.0.1:16384"
|
CliAuthProxy = "LARKSUITE_CLI_AUTH_PROXY" // sidecar address http(s)://host[:port]; plaintext http is same-host only, a remote sidecar must use https. e.g. "http://127.0.0.1:16384" or "https://sidecar.mycorp.com"
|
||||||
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
CliProxyKey = "LARKSUITE_CLI_PROXY_KEY" // HMAC signing key shared with sidecar
|
||||||
|
|
||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||||
|
|
||||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
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
|
Brand string // "feishu" | "lark" — drives console_url host
|
||||||
AppID string // placed in console_url
|
AppID string // placed in console_url
|
||||||
Identity string // "user" / "bot" / "" — caller converts core.Identity at the boundary
|
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.
|
// BuildAPIError consumes a parsed Lark API response and returns a typed error.
|
||||||
@@ -35,7 +36,7 @@ type ClassifyContext struct {
|
|||||||
// Network → *errs.NetworkError
|
// Network → *errs.NetworkError
|
||||||
// Internal → *errs.InternalError
|
// Internal → *errs.InternalError
|
||||||
// Confirmation → *errs.ConfirmationRequiredError
|
// 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
|
// Unknown Lark codes (LookupCodeMeta returns false) fall back to
|
||||||
// CategoryAPI + SubtypeUnknown.
|
// CategoryAPI + SubtypeUnknown.
|
||||||
@@ -80,6 +81,17 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|||||||
LogID: logID,
|
LogID: logID,
|
||||||
Retryable: meta.Retryable,
|
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 {
|
switch meta.Category {
|
||||||
case errs.CategoryAuthorization:
|
case errs.CategoryAuthorization:
|
||||||
@@ -87,7 +99,7 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|||||||
case errs.CategoryAuthentication:
|
case errs.CategoryAuthentication:
|
||||||
return &errs.AuthenticationError{Problem: base}
|
return &errs.AuthenticationError{Problem: base}
|
||||||
case errs.CategoryConfig:
|
case errs.CategoryConfig:
|
||||||
return &errs.ConfigError{Problem: base}
|
return buildConfigError(base)
|
||||||
case errs.CategoryPolicy:
|
case errs.CategoryPolicy:
|
||||||
return buildSecurityPolicyError(base, resp)
|
return buildSecurityPolicyError(base, resp)
|
||||||
case errs.CategoryValidation:
|
case errs.CategoryValidation:
|
||||||
@@ -97,9 +109,39 @@ func BuildAPIError(resp map[string]any, cc ClassifyContext) error {
|
|||||||
case errs.CategoryInternal:
|
case errs.CategoryInternal:
|
||||||
return &errs.InternalError{Problem: base}
|
return &errs.InternalError{Problem: base}
|
||||||
case errs.CategoryConfirmation:
|
case errs.CategoryConfirmation:
|
||||||
return &errs.ConfirmationRequiredError{Problem: base}
|
// Risk + Action are non-omitempty wire fields. Derive from
|
||||||
|
// CodeMeta when available; otherwise emit RiskUnknown +
|
||||||
|
// ctx.LarkCmd placeholder so the envelope is never wire-invalid.
|
||||||
|
risk := meta.Risk
|
||||||
|
if risk == "" {
|
||||||
|
risk = errs.RiskUnknown
|
||||||
|
}
|
||||||
|
action := meta.Action
|
||||||
|
if action == "" {
|
||||||
|
action = cc.LarkCmd
|
||||||
|
}
|
||||||
|
if action == "" {
|
||||||
|
action = "unknown"
|
||||||
|
}
|
||||||
|
return &errs.ConfirmationRequiredError{
|
||||||
|
Problem: base,
|
||||||
|
Risk: risk,
|
||||||
|
Action: action,
|
||||||
|
}
|
||||||
|
case errs.CategoryAPI:
|
||||||
|
return &errs.APIError{Problem: base}
|
||||||
default:
|
default:
|
||||||
return &errs.APIError{Problem: base, Detail: resp}
|
// Fail closed: an unrecognized Category routes to InternalError
|
||||||
|
// instead of emitting an empty Problem on the wire.
|
||||||
|
return &errs.InternalError{
|
||||||
|
Problem: errs.Problem{
|
||||||
|
Category: errs.CategoryInternal,
|
||||||
|
Subtype: errs.SubtypeSDKError,
|
||||||
|
Code: base.Code,
|
||||||
|
Message: fmt.Sprintf("unrecognized Category %q for code %d", base.Category, base.Code),
|
||||||
|
LogID: base.LogID,
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +191,7 @@ func buildSecurityPolicyError(p errs.Problem, resp map[string]any) *errs.Securit
|
|||||||
|
|
||||||
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
// isHTTPSURL is the local-to-errclass duplicate of internal/auth/transport.go's
|
||||||
// isValidChallengeURL. Kept local to avoid coupling errclass to internal/auth;
|
// 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 {
|
func isHTTPSURL(rawURL string) bool {
|
||||||
if rawURL == "" {
|
if rawURL == "" {
|
||||||
return false
|
return false
|
||||||
@@ -167,47 +209,142 @@ func stringFromAny(v any) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildConfigError enriches a typed ConfigError with the canonical
|
||||||
|
// per-subtype recovery hint before returning it, so the wire envelope
|
||||||
|
// emitted via BuildAPIError always carries a hint for known config subtypes.
|
||||||
|
func buildConfigError(p errs.Problem) *errs.ConfigError {
|
||||||
|
p.Hint = ConfigHint(p.Subtype)
|
||||||
|
return &errs.ConfigError{Problem: p}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigHint returns the canonical per-subtype recovery hint for a typed
|
||||||
|
// ConfigError emitted via BuildAPIError.
|
||||||
|
func ConfigHint(subtype errs.Subtype) string {
|
||||||
|
switch subtype {
|
||||||
|
case errs.SubtypeInvalidClient:
|
||||||
|
return "run `lark-cli config init` to set valid app_id and app_secret"
|
||||||
|
case errs.SubtypeNotConfigured:
|
||||||
|
return "run `lark-cli config init` to set up app_id and app_secret"
|
||||||
|
case errs.SubtypeInvalidConfig:
|
||||||
|
return "check the config file for syntax errors; rerun `lark-cli config init` to reset"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
func buildPermissionError(p errs.Problem, resp map[string]any, cc ClassifyContext) *errs.PermissionError {
|
||||||
missing := extractMissingScopes(resp)
|
missing := extractMissingScopes(resp)
|
||||||
identity := cc.Identity
|
identity := cc.Identity
|
||||||
if identity == "" {
|
if identity == "" {
|
||||||
identity = "user"
|
identity = "user"
|
||||||
}
|
}
|
||||||
p.Hint = PermissionHint(missing, identity, p.Subtype)
|
consoleURL := ConsoleURL(cc.Brand, cc.AppID, missing)
|
||||||
return &errs.PermissionError{
|
p.Message = CanonicalPermissionMessage(p.Subtype, cc.AppID, missing, p.Message)
|
||||||
|
p.Hint = PermissionHint(missing, identity, p.Subtype, consoleURL)
|
||||||
|
permErr := &errs.PermissionError{
|
||||||
Problem: p,
|
Problem: p,
|
||||||
MissingScopes: missing,
|
MissingScopes: missing,
|
||||||
Identity: identity,
|
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
|
// CanonicalPermissionMessage returns the CLI-side canonical wording for a
|
||||||
// error. User identity with a missing user-scope is recovered by re-running
|
// typed PermissionError, preserving the Lark official-API phrasing
|
||||||
// `auth login --scope ...`; bot identity or app-level scope errors are
|
// ("access denied" / "unauthorized" / "token has no permission") and
|
||||||
// recovered by enabling scopes in the open-platform console. The subtype
|
// enhancing it with CLI context (app ID, missing scope list). Subtypes
|
||||||
// argument distinguishes app-level failures (e.g. SubtypeAppScopeNotApplied)
|
// outside the known set fall through to fallback so the upstream message
|
||||||
// where re-authentication will not help regardless of the caller identity.
|
// 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
|
// Exported so direct construction sites (cmd/service/service.go's
|
||||||
// checkServiceScopes) can produce hints that match the dispatcher path
|
// checkServiceScopes) can produce hints that match the dispatcher path
|
||||||
// byte-for-byte instead of hand-rolling divergent strings.
|
// byte-for-byte instead of hand-rolling divergent strings.
|
||||||
func PermissionHint(missing []string, identity string, subtype errs.Subtype) string {
|
func PermissionHint(missing []string, identity string, subtype errs.Subtype, consoleURL string) string {
|
||||||
// app_scope_not_enabled means the scope has not been granted at the
|
switch subtype {
|
||||||
// app (developer console) level — re-authenticating cannot fix it,
|
case errs.SubtypeAppScopeNotApplied:
|
||||||
// so route every caller identity to the console hint.
|
if consoleURL != "" {
|
||||||
useConsole := identity == "bot" || subtype == errs.SubtypeAppScopeNotApplied
|
return fmt.Sprintf("the app developer must apply for the required scope(s) at the developer console: %s", consoleURL)
|
||||||
if len(missing) == 0 {
|
|
||||||
if useConsole {
|
|
||||||
return "check the app's scope grant in the Lark open platform console"
|
|
||||||
}
|
}
|
||||||
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, " ")
|
return "check the calling identity has the required scope"
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractMissingScopes walks resp["error"]["permission_violations"][].subject.
|
// 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) {
|
func TestBuildAPIError_NilAndZeroCode(t *testing.T) {
|
||||||
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
|
if got := errclass.BuildAPIError(nil, errclass.ClassifyContext{}); got != nil {
|
||||||
t.Errorf("nil resp should return nil error, got %v", got)
|
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"},
|
{"99991676 token_no_permission", 99991676, errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, 3, "PermissionError"},
|
||||||
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
|
{"99991679 missing_scope", 99991679, errs.CategoryAuthorization, errs.SubtypeMissingScope, 3, "PermissionError"},
|
||||||
{"230027 user_not_authorized", 230027, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, 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"},
|
{"1470403 task_permission_denied", 1470403, errs.CategoryAuthorization, errs.SubtypePermissionDenied, 3, "PermissionError"},
|
||||||
{"1470400 task_invalid_params", 1470400, errs.CategoryValidation, errs.Subtype("task_invalid_params"), 2, "ValidationError"},
|
{"1470400 task_invalid_params", 1470400, errs.CategoryAPI, errs.SubtypeInvalidParameters, 1, "APIError"},
|
||||||
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
{"99991400 rate_limit", 99991400, errs.CategoryAPI, errs.SubtypeRateLimit, 1, "APIError"},
|
||||||
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
{"99991661 token_missing", 99991661, errs.CategoryAuthentication, errs.SubtypeTokenMissing, 3, "AuthenticationError"},
|
||||||
{"21000 challenge_required", 21000, errs.CategoryPolicy, errs.Subtype("challenge_required"), 6, "SecurityPolicyError"},
|
{"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
|
// TestBuildAPIError_TaskInvalidParamsRoutesToAPIError pins that code 1470400
|
||||||
// (taskCodeMeta → CategoryValidation) produces *errs.ValidationError, not
|
// (Lark API-side parameter rejection) routes to *errs.APIError + CategoryAPI
|
||||||
// the default *errs.APIError. The dispatcher must read codeMeta.Category and
|
// + SubtypeInvalidParameters. CategoryValidation is reserved for CLI-side
|
||||||
// route accordingly so the embedded Problem.Category matches the wire type.
|
// (caller-side) flag/arg validation, never reachable from API responses;
|
||||||
func TestBuildAPIError_ValidationRoutesToValidationError(t *testing.T) {
|
// 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"}
|
resp := map[string]any{"code": 1470400, "msg": "bad params"}
|
||||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for code 1470400")
|
t.Fatal("expected error for code 1470400")
|
||||||
}
|
}
|
||||||
var ve *errs.ValidationError
|
var ae *errs.APIError
|
||||||
if !errors.As(err, &ve) {
|
if !errors.As(err, &ae) {
|
||||||
t.Fatalf("expected *errs.ValidationError, got %T", err)
|
t.Fatalf("expected *errs.APIError, got %T", err)
|
||||||
}
|
|
||||||
if _, isAPI := err.(*errs.APIError); isAPI {
|
|
||||||
t.Fatalf("unexpected *errs.APIError fallthrough (F2 regression): %T", err)
|
|
||||||
}
|
}
|
||||||
p, ok := errs.ProblemOf(err)
|
p, ok := errs.ProblemOf(err)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("ProblemOf returned !ok")
|
t.Fatal("ProblemOf returned !ok")
|
||||||
}
|
}
|
||||||
if p.Category != errs.CategoryValidation {
|
if p.Category != errs.CategoryAPI {
|
||||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
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`,
|
`"code": 99991679`,
|
||||||
`"missing_scopes":`,
|
`"missing_scopes":`,
|
||||||
`"docx:document"`,
|
`"docx:document"`,
|
||||||
`"console_url":`,
|
|
||||||
`open.feishu.cn/app/cli_a123/auth`,
|
|
||||||
`"identity": "user"`,
|
`"identity": "user"`,
|
||||||
`"log_id": "lg-1"`,
|
`"log_id": "lg-1"`,
|
||||||
} {
|
} {
|
||||||
@@ -196,6 +273,12 @@ func TestPermissionErrorEnvelopeShape(t *testing.T) {
|
|||||||
`"component"`,
|
`"component"`,
|
||||||
`"doc_url"`,
|
`"doc_url"`,
|
||||||
`"retryable":`, // Retryable defaults false, omitempty → key absent
|
`"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) {
|
if strings.Contains(out, mustNot) {
|
||||||
t.Errorf("envelope must not contain %q\nfull: %s", mustNot, out)
|
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) {
|
func TestConsoleURL_FeishuBrand(t *testing.T) {
|
||||||
resp := missingScopeResp("docx:document")
|
resp := appScopeNotAppliedResp("docx:document")
|
||||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "bot"})
|
||||||
pe, ok := err.(*errs.PermissionError)
|
pe, ok := err.(*errs.PermissionError)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
@@ -240,8 +323,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConsoleURL_LarkBrand(t *testing.T) {
|
func TestConsoleURL_LarkBrand(t *testing.T) {
|
||||||
resp := missingScopeResp("docx:document")
|
resp := appScopeNotAppliedResp("docx:document")
|
||||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "user"})
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "lark", AppID: "cli_a123", Identity: "bot"})
|
||||||
pe, ok := err.(*errs.PermissionError)
|
pe, ok := err.(*errs.PermissionError)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
t.Fatalf("expected *errs.PermissionError, got %T", err)
|
||||||
@@ -252,14 +335,36 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestConsoleURL_EmptyAppID(t *testing.T) {
|
func TestConsoleURL_EmptyAppID(t *testing.T) {
|
||||||
resp := missingScopeResp("docx:document")
|
resp := appScopeNotAppliedResp("docx:document")
|
||||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "user"})
|
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "", Identity: "bot"})
|
||||||
pe := err.(*errs.PermissionError)
|
pe := err.(*errs.PermissionError)
|
||||||
if pe.ConsoleURL != "" {
|
if pe.ConsoleURL != "" {
|
||||||
t.Errorf("ConsoleURL with empty AppID should be empty; got %q", 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
|
// TestConsoleURL_EscapesDangerousChars pins that ConsoleURL escapes appID and
|
||||||
// scope values so a hostile value cannot break out of the URL framing
|
// scope values so a hostile value cannot break out of the URL framing
|
||||||
// (e.g. by smuggling extra `&` parameters or a `#` fragment).
|
// (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) {
|
func TestPermissionError_NoViolations(t *testing.T) {
|
||||||
// permission error without a permission_violations array → MissingScopes nil,
|
// permission error without a permission_violations array → MissingScopes nil,
|
||||||
// ConsoleURL falls back to the no-scope form.
|
// ConsoleURL falls back to the no-scope form. Exercises the bot-perspective
|
||||||
resp := map[string]any{"code": 99991679, "msg": "x"}
|
// SubtypeAppScopeNotApplied envelope since that is where ConsoleURL rides.
|
||||||
err := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: "feishu", AppID: "cli_a123", Identity: "user"})
|
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)
|
pe := err.(*errs.PermissionError)
|
||||||
if pe.MissingScopes != nil {
|
if pe.MissingScopes != nil {
|
||||||
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
|
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
|
// TestServiceShortcutEnvelopeConverge guards that the wire envelope produced
|
||||||
// identical whether produced via the dispatcher (BuildAPIError — the normal
|
// by the dispatcher (BuildAPIError — the normal service / shortcut path)
|
||||||
// service / shortcut path) or constructed directly at the call site (the
|
// converges with the envelope produced by the direct-construction path used
|
||||||
// cmd/service permission path).
|
// in cmd/service/service.go's checkServiceScopes pre-flight check.
|
||||||
//
|
//
|
||||||
// cmd/service/service.go's checkServiceScopes builds PermissionError using the
|
// Both paths now share the same canonical helpers in internal/errclass for
|
||||||
// exported PermissionHint and ConsoleURL helpers — the same helpers
|
// Message (CanonicalPermissionMessage), Hint (PermissionHint), and
|
||||||
// BuildAPIError uses. The hand-constructed branch below intentionally mirrors
|
// ConsoleURL (ConsoleURL); MissingScopes and Identity are filled identically.
|
||||||
// service.go line-by-line so a future drift on either side (e.g. a new
|
// A future drift on either side (e.g. a new extension field on
|
||||||
// extension field on PermissionError that only BuildAPIError populates) fails
|
// PermissionError that only BuildAPIError populates, or service.go inlining
|
||||||
// loudly here. The remaining limitation is that this test invokes the helpers
|
// its own message string again) fails this test loudly.
|
||||||
// directly rather than driving checkServiceScopes (which requires a credential
|
//
|
||||||
// + factory mock). TODO: lift this into cmd/service_test.go once a lightweight
|
// One upstream-derived field is a documented exception: `code` (the Lark
|
||||||
// mock harness lands.
|
// 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) {
|
func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
||||||
const (
|
const (
|
||||||
brand = "feishu"
|
brand = "feishu"
|
||||||
@@ -392,27 +502,21 @@ func TestServiceShortcutEnvelopeConverge(t *testing.T) {
|
|||||||
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
|
// Path A: dispatcher — BuildAPIError parsing a Lark API response.
|
||||||
resp := missingScopeResp(missing[0])
|
resp := missingScopeResp(missing[0])
|
||||||
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
|
dispatcherErr := errclass.BuildAPIError(resp, errclass.ClassifyContext{Brand: brand, AppID: appID, Identity: identity})
|
||||||
dispatcherPE, ok := dispatcherErr.(*errs.PermissionError)
|
if _, ok := dispatcherErr.(*errs.PermissionError); !ok {
|
||||||
if !ok {
|
|
||||||
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
|
t.Fatalf("BuildAPIError did not return *PermissionError, got %T", dispatcherErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path B: direct construction — exactly mirrors cmd/service/service.go's
|
// Path B: direct construction — exercises the same helpers that
|
||||||
// checkServiceScopes (same helpers, same field-fill order). Code
|
// cmd/service/service.go's newPreflightMissingScopeError uses. Keep this
|
||||||
// and Message are copied from Path A so the byte-comparison below isolates
|
// in lock-step with that helper; if either drifts the byte-comparison
|
||||||
// the contract under test (Hint + Identity + ConsoleURL convergence).
|
// fails. ConsoleURL is intentionally NOT set on either path for
|
||||||
directErr := &errs.PermissionError{
|
// SubtypeMissingScope — see the gating rationale in buildPermissionError.
|
||||||
Problem: errs.Problem{
|
consoleURL := errclass.ConsoleURL(brand, appID, missing)
|
||||||
Category: errs.CategoryAuthorization,
|
directErr := errs.NewPermissionError(errs.SubtypeMissingScope,
|
||||||
Subtype: errs.SubtypeMissingScope,
|
"%s", errclass.CanonicalPermissionMessage(errs.SubtypeMissingScope, appID, missing, "")).
|
||||||
Code: dispatcherPE.Code,
|
WithHint("%s", errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope, consoleURL)).
|
||||||
Message: dispatcherPE.Message,
|
WithMissingScopes(missing...).
|
||||||
Hint: errclass.PermissionHint(missing, identity, errs.SubtypeMissingScope),
|
WithIdentity(identity)
|
||||||
},
|
|
||||||
MissingScopes: missing,
|
|
||||||
Identity: identity,
|
|
||||||
ConsoleURL: errclass.ConsoleURL(brand, appID, missing),
|
|
||||||
}
|
|
||||||
|
|
||||||
var bufA, bufB bytes.Buffer
|
var bufA, bufB bytes.Buffer
|
||||||
if ok := output.WriteTypedErrorEnvelope(&bufA, dispatcherErr, identity); !ok {
|
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")
|
t.Fatal("direct path failed to emit typed envelope")
|
||||||
}
|
}
|
||||||
|
|
||||||
if bufA.String() != bufB.String() {
|
// Strip `code` from both envelopes — see test doc above.
|
||||||
t.Errorf("dispatcher vs direct-construction envelopes diverge:\nDispatcher: %s\nDirect: %s", bufA.String(), bufB.String())
|
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) {
|
func TestDirectPermissionPath_TypedExitCode(t *testing.T) {
|
||||||
// Mirrors what the cmd/service direct-construction path produces.
|
// Mirrors what the cmd/service direct-construction path produces.
|
||||||
pe := &errs.PermissionError{
|
pe := &errs.PermissionError{
|
||||||
@@ -492,44 +619,48 @@ func TestBuildAPIError_LogIDTopLevel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPermissionHint_UserWithScopes(t *testing.T) {
|
func TestBuildPermissionHint_MissingScopeRoutesToAuthLogin(t *testing.T) {
|
||||||
got := errclass.PermissionHint([]string{"docx:document", "im:message"}, "user", errs.SubtypeMissingScope)
|
// missing_scope means the user authorized the app but did not grant
|
||||||
if !strings.Contains(got, "lark-cli auth login") {
|
// this scope — recoverable by re-running `auth login`. Both user and
|
||||||
t.Errorf("user hint should suggest `lark-cli auth login`; got %q", got)
|
// bot identities route the same way because the recovery action is
|
||||||
}
|
// user-initiated either way.
|
||||||
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
|
for _, identity := range []string{"user", "bot", ""} {
|
||||||
t.Errorf("user hint should include missing scopes; got %q", got)
|
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)
|
||||||
|
}
|
||||||
func TestBuildPermissionHint_BotWithScopes(t *testing.T) {
|
if !strings.Contains(got, "docx:document") || !strings.Contains(got, "im:message") {
|
||||||
got := errclass.PermissionHint([]string{"docx:document"}, "bot", errs.SubtypeMissingScope)
|
t.Errorf("identity=%q: hint should include missing scopes; got %q", identity, got)
|
||||||
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_NoScopes(t *testing.T) {
|
func TestBuildPermissionHint_NoScopes(t *testing.T) {
|
||||||
if got := errclass.PermissionHint(nil, "user", errs.SubtypeMissingScope); !strings.Contains(got, "required scopes") {
|
// missing_scope with empty list — still suggests auth login even
|
||||||
t.Errorf("user no-scope hint missing fallback wording; got %q", got)
|
// 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") {
|
// app_scope_not_applied without console URL — still points at the
|
||||||
t.Errorf("bot no-scope hint should still point at the console; got %q", got)
|
// 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) {
|
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
|
// at the app level — re-authenticating cannot fix it. The hint must
|
||||||
// point to the developer console regardless of caller identity, or
|
// point to the developer console regardless of caller identity, or
|
||||||
// agents will loop on `auth login` forever.
|
// 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", ""} {
|
for _, identity := range []string{"user", "bot", ""} {
|
||||||
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied)
|
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
|
||||||
if !strings.Contains(got, "open platform console") {
|
if !strings.Contains(got, "developer console") {
|
||||||
t.Errorf("identity=%q: hint should point to console; got %q", identity, got)
|
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") {
|
if strings.Contains(got, "auth login") {
|
||||||
t.Errorf("identity=%q: hint must not suggest `auth login`; got %q", identity, got)
|
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) {
|
func TestBuildAPIError_AppMissingScope_UserIdentityHintRoutesToConsole(t *testing.T) {
|
||||||
// Regression: code 99991672 with user identity previously emitted
|
// Regression: code 99991672 with user identity previously emitted
|
||||||
// `lark-cli auth login --scope ...` which sends agents into a re-auth
|
// `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 {
|
if p.Subtype != errs.SubtypeAppScopeNotApplied {
|
||||||
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeAppScopeNotApplied)
|
||||||
}
|
}
|
||||||
if !strings.Contains(p.Hint, "open platform console") {
|
if !strings.Contains(p.Hint, "developer console") {
|
||||||
t.Errorf("Hint should route to console; got %q", p.Hint)
|
t.Errorf("Hint should route to developer console; got %q", p.Hint)
|
||||||
}
|
}
|
||||||
if strings.Contains(p.Hint, "auth login") {
|
if strings.Contains(p.Hint, "auth login") {
|
||||||
t.Errorf("Hint must not suggest `auth login` for app-level scope errors; got %q", p.Hint)
|
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.
|
// CodeMeta is the classification metadata attached to a Lark numeric code.
|
||||||
// It does NOT carry Message or Hint — those are derived at the dispatcher
|
// It does NOT carry Message or Hint — those are derived at the dispatcher
|
||||||
// (see BuildAPIError).
|
// (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 {
|
type CodeMeta struct {
|
||||||
Category errs.Category
|
Category errs.Category
|
||||||
Subtype errs.Subtype
|
Subtype errs.Subtype
|
||||||
Retryable bool
|
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/
|
// 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.
|
// so sub-tables registering via init() can always assume codeMeta is non-nil.
|
||||||
var codeMeta = map[int]CodeMeta{
|
var codeMeta = map[int]CodeMeta{
|
||||||
// CategoryAuthentication
|
// CategoryAuthentication
|
||||||
99991661: {errs.CategoryAuthentication, errs.SubtypeTokenMissing, false}, // Authorization header missing
|
99991661: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenMissing}, // Authorization header missing
|
||||||
99991671: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // token format error (must start with t- / u-)
|
99991671: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // token format error (must start with t- / u-)
|
||||||
99991668: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // UAT invalid/expired (server does not distinguish)
|
99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish)
|
||||||
99991663: {errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false}, // access_token invalid
|
99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid
|
||||||
99991677: {errs.CategoryAuthentication, errs.SubtypeTokenExpired, false}, // UAT expired
|
99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired
|
||||||
20026: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false}, // refresh_token v1 legacy format
|
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format
|
||||||
20037: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false}, // refresh_token expired
|
20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired
|
||||||
20064: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false}, // refresh_token revoked
|
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
|
||||||
20073: {errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false}, // refresh_token already used
|
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
|
||||||
20050: {errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true}, // refresh endpoint transient error
|
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error
|
||||||
|
|
||||||
// CategoryAuthorization
|
// CategoryAuthorization
|
||||||
99991672: {errs.CategoryAuthorization, errs.SubtypeAppScopeNotApplied, false},
|
99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied},
|
||||||
99991676: {errs.CategoryAuthorization, errs.SubtypeTokenScopeInsufficient, false},
|
99991676: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeTokenScopeInsufficient},
|
||||||
99991679: {errs.CategoryAuthorization, errs.SubtypeMissingScope, false}, // user authorized app but did not grant this scope
|
99991679: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeMissingScope}, // user authorized app but did not grant this scope
|
||||||
230027: {errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false}, // user never authorized the app
|
230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app
|
||||||
99991673: {errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false}, // app status unavailable
|
99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable
|
||||||
99991662: {errs.CategoryAuthorization, errs.SubtypeAppNotInstalled, false}, // app not enabled / not installed in tenant
|
99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled in tenant
|
||||||
|
|
||||||
// CategoryAPI
|
// CategoryAPI
|
||||||
99991400: {errs.CategoryAPI, errs.SubtypeRateLimit, true},
|
99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true},
|
||||||
1061045: {errs.CategoryAPI, errs.SubtypeConflict, true},
|
1061045: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true},
|
||||||
131009: {errs.CategoryAPI, errs.SubtypeConflict, true}, // wiki write-path lock contention; retryable with backoff
|
131009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // wiki write-path lock contention; retryable with backoff
|
||||||
1064510: {errs.CategoryAPI, errs.SubtypeCrossTenant, false},
|
1064510: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossTenant},
|
||||||
1064511: {errs.CategoryAPI, errs.SubtypeCrossBrand, false},
|
1064511: {Category: errs.CategoryAPI, Subtype: errs.SubtypeCrossBrand},
|
||||||
1310246: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
1310246: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
|
||||||
1063006: {errs.CategoryAPI, errs.SubtypeRateLimit, false}, // drive perm-apply quota; 5/day, not short-term retryable
|
1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable
|
||||||
1063007: {errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
|
1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
|
||||||
231205: {errs.CategoryAPI, errs.SubtypeOwnershipMismatch, false},
|
231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch},
|
||||||
|
|
||||||
// CategoryConfig
|
// 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
|
// CategoryPolicy
|
||||||
21000: {errs.CategoryPolicy, errs.SubtypeChallengeRequired, false},
|
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},
|
||||||
21001: {errs.CategoryPolicy, errs.SubtypeAccessDenied, false},
|
21001: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeAccessDenied},
|
||||||
}
|
}
|
||||||
|
|
||||||
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
|
// LookupCodeMeta is the single lookup entry. Returns ok=false for unknown codes —
|
||||||
|
|||||||
@@ -5,20 +5,21 @@ package errclass
|
|||||||
|
|
||||||
import "github.com/larksuite/cli/errs"
|
import "github.com/larksuite/cli/errs"
|
||||||
|
|
||||||
// taskCodeMeta holds the task-service-specific Lark code classifications.
|
// taskCodeMeta holds task-service Lark code → CodeMeta mappings.
|
||||||
// 1470403 permission_denied is CategoryAuthorization (exit 3); the other task
|
// All Subtypes are framework-shared (errs.SubtypeXxx) — task does not declare
|
||||||
// codes route to CategoryAPI / CategoryValidation. BuildAPIError consumes this
|
// service-specific Subtypes because none of these codes carry semantics beyond
|
||||||
// map via mergeCodeMeta + LookupCodeMeta.
|
// the cross-service taxonomy (NotFound / QuotaExceeded / etc.).
|
||||||
|
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
|
||||||
var taskCodeMeta = map[int]CodeMeta{
|
var taskCodeMeta = map[int]CodeMeta{
|
||||||
1470400: {errs.CategoryValidation, errs.SubtypeTaskInvalidParams, false},
|
1470400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid_params
|
||||||
1470403: {errs.CategoryAuthorization, errs.SubtypeTaskPermissionDenied, false}, // permission_denied
|
1470403: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // permission_denied (resource-level)
|
||||||
1470404: {errs.CategoryAPI, errs.SubtypeTaskNotFound, false},
|
1470404: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // not_found
|
||||||
1470422: {errs.CategoryAPI, errs.SubtypeTaskConflict, true},
|
1470422: {Category: errs.CategoryAPI, Subtype: errs.SubtypeConflict, Retryable: true}, // conflict (retryable)
|
||||||
1470500: {errs.CategoryAPI, errs.SubtypeTaskServerError, true},
|
1470500: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // server_error (retryable)
|
||||||
1470610: {errs.CategoryAPI, errs.SubtypeTaskAssigneeLimit, false},
|
1470610: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // assignee_limit
|
||||||
1470611: {errs.CategoryAPI, errs.SubtypeTaskFollowerLimit, false},
|
1470611: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // follower_limit
|
||||||
1470612: {errs.CategoryAPI, errs.SubtypeTaskTasklistMemberLimit, false},
|
1470612: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // tasklist_member_limit
|
||||||
1470613: {errs.CategoryAPI, errs.SubtypeTaskReminderExists, false},
|
1470613: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // reminder_exists
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { mergeCodeMeta(taskCodeMeta, "task") }
|
func init() { mergeCodeMeta(taskCodeMeta, "task") }
|
||||||
|
|||||||
@@ -4,12 +4,45 @@
|
|||||||
package errclass
|
package errclass
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"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) {
|
func TestLookupCodeMeta_MissingScope(t *testing.T) {
|
||||||
got, ok := LookupCodeMeta(99991679)
|
got, ok := LookupCodeMeta(99991679)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -29,8 +62,8 @@ func TestLookupCodeMeta_TaskPermissionDenied_MergedViaInit(t *testing.T) {
|
|||||||
if got.Category != errs.CategoryAuthorization {
|
if got.Category != errs.CategoryAuthorization {
|
||||||
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
|
||||||
}
|
}
|
||||||
if got.Subtype != errs.Subtype("task_permission_denied") {
|
if got.Subtype != errs.SubtypePermissionDenied {
|
||||||
t.Errorf("Subtype = %q, want %q", got.Subtype, "task_permission_denied")
|
t.Errorf("Subtype = %q, want %q", got.Subtype, errs.SubtypePermissionDenied)
|
||||||
}
|
}
|
||||||
if got.Retryable {
|
if got.Retryable {
|
||||||
t.Errorf("Retryable = true, want false")
|
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) {
|
func TestLookupCodeMeta_PolicyChallengeRequired(t *testing.T) {
|
||||||
got, ok := LookupCodeMeta(21000)
|
got, ok := LookupCodeMeta(21000)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -93,7 +147,7 @@ func TestMergeCodeMeta_PanicsOnDuplicate(t *testing.T) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("panic value is not a string: %T (%v)", r, r)
|
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) {
|
if !strings.Contains(msg, needle) {
|
||||||
t.Errorf("panic message %q missing substring %q", msg, needle)
|
t.Errorf("panic message %q missing substring %q", msg, needle)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,48 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
// Package errcompat bridges the legacy *core.ConfigError shape into the
|
// Package errcompat provides boundary helpers that bridge legacy error types
|
||||||
// canonical typed errors taxonomy in errs/. It is a thin boundary helper —
|
// to the typed errs/ taxonomy. These helpers run at the dispatcher boundary
|
||||||
// placed in its own package so it can import both core (for the legacy
|
// (cmd/root.go.handleRootError) before the typed envelope writer, converting
|
||||||
// type) and errs (for the typed targets) without creating an import cycle
|
// pre-typed-taxonomy errors (*core.ConfigError, *internalauth.NeedAuthorizationError)
|
||||||
// with internal/errclass, which intentionally avoids depending on
|
// into typed *errs.* errors while preserving the original error in the Cause
|
||||||
// internal/core.
|
// chain so existing `errors.As` callers continue to match.
|
||||||
package errcompat
|
package errcompat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PromoteConfigError is the stage-2 boundary helper that will convert a
|
// PromoteConfigError converts a legacy *core.ConfigError into the matching
|
||||||
// *core.ConfigError into the matching typed errs.* error. In stage 1 it
|
// typed errs.*Error based on cfgErr.Type. Called from cmd/root.go.handleRootError
|
||||||
// is a passthrough — the dispatcher continues to render *core.ConfigError
|
// before the typed envelope writer. The original *core.ConfigError is preserved
|
||||||
// via the legacy envelope path (cmd/root.go asExitError) so the wire
|
// in the Cause chain so external `errors.As(&core.ConfigError{})` callers
|
||||||
// shape stays identical to pre-PR. Per-domain typed migration in stage 2+
|
// (cmd/auth/list.go, cmd/doctor/doctor.go, etc.) still match.
|
||||||
// will fill in the actual promotion logic alongside its corresponding
|
|
||||||
// wire-change announcement.
|
|
||||||
func PromoteConfigError(cfgErr *core.ConfigError) error {
|
func PromoteConfigError(cfgErr *core.ConfigError) error {
|
||||||
if cfgErr == nil {
|
if cfgErr == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return cfgErr
|
switch cfgErr.Type {
|
||||||
|
case "auth":
|
||||||
|
return errs.NewAuthenticationError(errs.SubtypeTokenMissing, "%s", cfgErr.Message).
|
||||||
|
WithHint("%s", cfgErr.Hint).
|
||||||
|
WithCause(cfgErr)
|
||||||
|
case "config":
|
||||||
|
subtype := errs.SubtypeNotConfigured
|
||||||
|
lower := strings.ToLower(cfgErr.Message)
|
||||||
|
if strings.Contains(lower, "parse") || strings.Contains(lower, "invalid") {
|
||||||
|
subtype = errs.SubtypeInvalidConfig
|
||||||
|
}
|
||||||
|
return errs.NewConfigError(subtype, "%s", cfgErr.Message).
|
||||||
|
WithHint("%s", cfgErr.Hint).
|
||||||
|
WithCause(cfgErr)
|
||||||
|
default:
|
||||||
|
// dynamic Type (e.g. workspace name like "bind"/"hermes"/"openclaw") → NotConfigured
|
||||||
|
return errs.NewConfigError(errs.SubtypeNotConfigured, "%s", cfgErr.Message).
|
||||||
|
WithHint("%s", cfgErr.Hint).
|
||||||
|
WithCause(cfgErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// _ keeps the errs import live so stage-2 fill-in does not need to re-add it.
|
|
||||||
var _ = errs.CategoryConfig
|
|
||||||
|
|||||||
32
internal/errcompat/promote_auth.go
Normal file
32
internal/errcompat/promote_auth.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errcompat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PromoteAuthError converts a legacy *internalauth.NeedAuthorizationError into
|
||||||
|
// *errs.AuthenticationError{Subtype: TokenMissing}. The Message field MUST
|
||||||
|
// contain "need_user_authorization" so the marker invariant guardrail in
|
||||||
|
// cmd/root_test.go and internal/auth/errors_test.go still holds.
|
||||||
|
//
|
||||||
|
// Hint mirrors newTokenMissingError in internal/client/client.go so both
|
||||||
|
// token-missing surfaces converge on the same recovery vocabulary. cmd's
|
||||||
|
// applyNeedAuthorizationHint appends per-command scopes onto this Hint with
|
||||||
|
// a "\n" join, so the action prompt is preserved even when scopes are added.
|
||||||
|
//
|
||||||
|
// Called from cmd/root.go.handleRootError when errors.As matches
|
||||||
|
// *NeedAuthorizationError, before WriteTypedErrorEnvelope.
|
||||||
|
func PromoteAuthError(err *internalauth.NeedAuthorizationError) error {
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return errs.NewAuthenticationError(errs.SubtypeTokenMissing,
|
||||||
|
"need_user_authorization (user: %s)", err.UserOpenId).
|
||||||
|
WithUserOpenID(err.UserOpenId).
|
||||||
|
WithHint("run: lark-cli auth login to re-authorize").
|
||||||
|
WithCause(err)
|
||||||
|
}
|
||||||
79
internal/errcompat/promote_auth_test.go
Normal file
79
internal/errcompat/promote_auth_test.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package errcompat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
|
internalauth "github.com/larksuite/cli/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPromoteAuthError_PromotesNeedAuthorizationError(t *testing.T) {
|
||||||
|
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
||||||
|
got := PromoteAuthError(needAuth)
|
||||||
|
|
||||||
|
var authErr *errs.AuthenticationError
|
||||||
|
if !errors.As(got, &authErr) {
|
||||||
|
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
|
||||||
|
}
|
||||||
|
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||||
|
t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cause chain must preserve original *NeedAuthorizationError so legacy
|
||||||
|
// consumers (auth.IsNeedUserAuthorizationError + errors.As pattern in
|
||||||
|
// internal/auth/errors.go:42) still match.
|
||||||
|
var preserved *internalauth.NeedAuthorizationError
|
||||||
|
if !errors.As(got, &preserved) {
|
||||||
|
t.Error("Unwrap chain lost *NeedAuthorizationError — breaks auth.IsNeedUserAuthorizationError consumer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteAuthError_PreservesNeedUserAuthorizationMarker(t *testing.T) {
|
||||||
|
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"}
|
||||||
|
got := PromoteAuthError(needAuth)
|
||||||
|
if !strings.Contains(got.Error(), "need_user_authorization") {
|
||||||
|
t.Errorf("Message must contain need_user_authorization marker, got: %q", got.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteAuthError_PreservesUserOpenID(t *testing.T) {
|
||||||
|
needAuth := &internalauth.NeedAuthorizationError{UserOpenId: "u_test_open_id"}
|
||||||
|
got := PromoteAuthError(needAuth)
|
||||||
|
|
||||||
|
var authErr *errs.AuthenticationError
|
||||||
|
if !errors.As(got, &authErr) {
|
||||||
|
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
|
||||||
|
}
|
||||||
|
if authErr.UserOpenID != "u_test_open_id" {
|
||||||
|
t.Errorf("UserOpenID = %q, want preserved", authErr.UserOpenID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPromoteAuthError_CarriesAuthLoginHint pins that the recovery action
|
||||||
|
// prompt is attached at promotion time — without this Hint, downstream
|
||||||
|
// consumers see authentication/token_missing but no "run: lark-cli auth login"
|
||||||
|
// guidance, mirroring the pre-typed UX failure when NeedAuthorizationError
|
||||||
|
// surfaced as a bare network error. cmd's applyNeedAuthorizationHint relies
|
||||||
|
// on this Hint being non-empty so scope enrichment appends instead of
|
||||||
|
// overwrites the recovery prompt.
|
||||||
|
func TestPromoteAuthError_CarriesAuthLoginHint(t *testing.T) {
|
||||||
|
got := PromoteAuthError(&internalauth.NeedAuthorizationError{UserOpenId: "u_xxx"})
|
||||||
|
var authErr *errs.AuthenticationError
|
||||||
|
if !errors.As(got, &authErr) {
|
||||||
|
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(authErr.Hint, "lark-cli auth login") {
|
||||||
|
t.Errorf("Hint must guide user to re-authorize, got: %q", authErr.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteAuthError_Nil_ReturnsNil(t *testing.T) {
|
||||||
|
if got := PromoteAuthError(nil); got != nil {
|
||||||
|
t.Errorf("nil input should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,33 +5,101 @@ package errcompat_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/errcompat"
|
"github.com/larksuite/cli/internal/errcompat"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestPromoteConfigError_Stage1Passthrough pins the stage-1 passthrough
|
func TestPromoteConfigError_TypeAuth_PromotesToAuthenticationError(t *testing.T) {
|
||||||
// behaviour: every input *core.ConfigError flows out unchanged so the
|
cfg := &core.ConfigError{
|
||||||
// dispatcher's legacy envelope path emits the same wire shape as pre-PR.
|
Type: "auth",
|
||||||
// Per-domain typed migration will replace this in stage 2+.
|
Code: 3,
|
||||||
func TestPromoteConfigError_Stage1Passthrough(t *testing.T) {
|
Message: "not logged in",
|
||||||
for _, cfgType := range []string{"config", "auth", "openclaw", ""} {
|
Hint: "run: lark-cli auth login",
|
||||||
t.Run(cfgType, func(t *testing.T) {
|
}
|
||||||
src := &core.ConfigError{Code: 3, Type: cfgType, Message: "msg", Hint: "hint"}
|
got := errcompat.PromoteConfigError(cfg)
|
||||||
out := errcompat.PromoteConfigError(src)
|
|
||||||
var got *core.ConfigError
|
var authErr *errs.AuthenticationError
|
||||||
if !errors.As(out, &got) || got != src {
|
if !errors.As(got, &authErr) {
|
||||||
t.Fatalf("Type=%q: expected passthrough of original *core.ConfigError, got %T (%v)", cfgType, out, out)
|
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
|
||||||
|
}
|
||||||
|
if authErr.Subtype != errs.SubtypeTokenMissing {
|
||||||
|
t.Errorf("subtype = %v, want %v", authErr.Subtype, errs.SubtypeTokenMissing)
|
||||||
|
}
|
||||||
|
// Cause chain must preserve original *core.ConfigError for errors.As compat.
|
||||||
|
var cfgPreserved *core.ConfigError
|
||||||
|
if !errors.As(got, &cfgPreserved) {
|
||||||
|
t.Error("Unwrap chain lost *core.ConfigError — breaks cmd/auth/list.go consumer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteConfigError_TypeConfig_PromotesToConfigError(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
msg string
|
||||||
|
wantSubtype errs.Subtype
|
||||||
|
}{
|
||||||
|
{"not_configured", "not configured", errs.SubtypeNotConfigured},
|
||||||
|
{"invalid_config_parse", "failed to parse config", errs.SubtypeInvalidConfig},
|
||||||
|
{"invalid_config_keyword", "invalid config file", errs.SubtypeInvalidConfig},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
cfg := &core.ConfigError{Type: "config", Code: 3, Message: tc.msg}
|
||||||
|
got := errcompat.PromoteConfigError(cfg)
|
||||||
|
|
||||||
|
var ce *errs.ConfigError
|
||||||
|
if !errors.As(got, &ce) {
|
||||||
|
t.Fatalf("expected *errs.ConfigError, got %T", got)
|
||||||
|
}
|
||||||
|
if ce.Subtype != tc.wantSubtype {
|
||||||
|
t.Errorf("subtype = %v, want %v", ce.Subtype, tc.wantSubtype)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPromoteConfigError_NilInputReturnsNil pins that PromoteConfigError on a
|
func TestPromoteConfigError_TypeDynamic_PromotesToConfigError(t *testing.T) {
|
||||||
// nil input returns nil rather than panicking on the (cfgErr.Type) access.
|
for _, wsName := range []string{"openclaw", "hermes", "bind"} {
|
||||||
func TestPromoteConfigError_NilInputReturnsNil(t *testing.T) {
|
t.Run(wsName, func(t *testing.T) {
|
||||||
if got := errcompat.PromoteConfigError(nil); got != nil {
|
cfg := &core.ConfigError{Type: wsName, Code: 3, Message: "not configured"}
|
||||||
t.Errorf("PromoteConfigError(nil) = %v, want nil", got)
|
got := errcompat.PromoteConfigError(cfg)
|
||||||
|
|
||||||
|
var ce *errs.ConfigError
|
||||||
|
if !errors.As(got, &ce) {
|
||||||
|
t.Fatalf("expected *errs.ConfigError, got %T", got)
|
||||||
|
}
|
||||||
|
if ce.Subtype != errs.SubtypeNotConfigured {
|
||||||
|
t.Errorf("subtype = %v, want %v", ce.Subtype, errs.SubtypeNotConfigured)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteConfigError_Nil_ReturnsNil(t *testing.T) {
|
||||||
|
if got := errcompat.PromoteConfigError(nil); got != nil {
|
||||||
|
t.Errorf("nil input should return nil, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteConfigError_PreservesMessageHint(t *testing.T) {
|
||||||
|
cfg := &core.ConfigError{
|
||||||
|
Type: "auth",
|
||||||
|
Message: "session expired (user: u_xxx)",
|
||||||
|
Hint: "re-authenticate",
|
||||||
|
}
|
||||||
|
got := errcompat.PromoteConfigError(cfg)
|
||||||
|
if !strings.Contains(got.Error(), "session expired") {
|
||||||
|
t.Errorf("message lost in promotion: %v", got)
|
||||||
|
}
|
||||||
|
var authErr *errs.AuthenticationError
|
||||||
|
if !errors.As(got, &authErr) {
|
||||||
|
t.Fatalf("expected *errs.AuthenticationError, got %T", got)
|
||||||
|
}
|
||||||
|
if authErr.Hint != "re-authenticate" {
|
||||||
|
t.Errorf("hint = %q, want preserved", authErr.Hint)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,13 +17,8 @@ import (
|
|||||||
// It is propagated up the call chain and handled by main.go to produce
|
// It is propagated up the call chain and handled by main.go to produce
|
||||||
// a JSON error envelope on stderr and the correct exit code.
|
// a JSON error envelope on stderr and the correct exit code.
|
||||||
//
|
//
|
||||||
// Deprecated: *output.ExitError is the legacy error type that predates the
|
// Deprecated: legacy error type. Return a typed *errs.XxxError instead
|
||||||
// typed error contract introduced by errs/. New code MUST NOT instantiate it
|
// (see errs/types.go).
|
||||||
// — return a typed *errs.XxxError (see errs/ for the available categories:
|
|
||||||
// *AuthenticationError / *PermissionError / *ValidationError / *NetworkError /
|
|
||||||
// *APIError / *InternalError / etc.). This type is retained only while
|
|
||||||
// existing call sites are migrated; it will be removed once they have moved
|
|
||||||
// to the typed surface.
|
|
||||||
type ExitError struct {
|
type ExitError struct {
|
||||||
Code int
|
Code int
|
||||||
Detail *ErrDetail
|
Detail *ErrDetail
|
||||||
@@ -47,12 +42,12 @@ func (e *ExitError) Unwrap() error {
|
|||||||
|
|
||||||
// MarkRaw sets Raw=true on an ExitError so that the dispatcher skips
|
// MarkRaw sets Raw=true on an ExitError so that the dispatcher skips
|
||||||
// enrichment (e.g. enrichPermissionError, enrichMissingScopeError) and
|
// enrichment (e.g. enrichPermissionError, enrichMissingScopeError) and
|
||||||
// preserves the original API error detail. Returns the original error
|
// preserves the upstream message verbatim. Returns the original error
|
||||||
// unchanged if it is not (or does not wrap) an ExitError.
|
// unchanged if it is not (or does not wrap) an ExitError.
|
||||||
//
|
//
|
||||||
// Used by `cmd/api` and other "passthrough" call sites where the caller
|
// Used by `cmd/api` and other "passthrough" call sites where the caller
|
||||||
// explicitly wants the raw Lark API detail (log_id, troubleshooter, etc.)
|
// wants the original Lark response wording rather than the enriched
|
||||||
// on the wire rather than the enriched message/hint variant.
|
// message/hint variant.
|
||||||
func MarkRaw(err error) error {
|
func MarkRaw(err error) error {
|
||||||
var exitErr *ExitError
|
var exitErr *ExitError
|
||||||
if errors.As(err, &exitErr) {
|
if errors.As(err, &exitErr) {
|
||||||
@@ -63,13 +58,8 @@ func MarkRaw(err error) error {
|
|||||||
|
|
||||||
// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w.
|
// WriteErrorEnvelope writes a JSON error envelope for the given ExitError to w.
|
||||||
//
|
//
|
||||||
// Deprecated: WriteErrorEnvelope is the legacy envelope writer paired with
|
// Deprecated: legacy envelope writer. Typed errors are dispatched by
|
||||||
// *output.ExitError, which predates the typed error contract introduced by
|
// cmd/root.go through WriteTypedErrorEnvelope.
|
||||||
// errs/. New code MUST NOT call this directly — return a typed *errs.XxxError
|
|
||||||
// from the command, and cmd/root.go handleRootError will dispatch through
|
|
||||||
// WriteTypedErrorEnvelope. This writer is retained only while existing
|
|
||||||
// *ExitError producers are migrated; it will be removed once they have moved
|
|
||||||
// to the typed surface.
|
|
||||||
func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
||||||
if err.Detail == nil {
|
if err.Detail == nil {
|
||||||
return
|
return
|
||||||
@@ -95,12 +85,8 @@ func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) {
|
|||||||
|
|
||||||
// Errorf creates an ExitError with the given code, type, and formatted message.
|
// Errorf creates an ExitError with the given code, type, and formatted message.
|
||||||
//
|
//
|
||||||
// Deprecated: Errorf belongs to the legacy *output.ExitError surface that
|
// Deprecated: construct a typed *errs.XxxError directly
|
||||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
// (e.g. errs.NewValidationError, errs.NewInternalError).
|
||||||
// use it — construct a typed *errs.XxxError directly (e.g.
|
|
||||||
// *errs.ValidationError, *errs.InternalError). This helper is retained only
|
|
||||||
// while existing call sites are migrated; it will be removed once they have
|
|
||||||
// moved to the typed surface.
|
|
||||||
func Errorf(code int, errType, format string, args ...any) *ExitError {
|
func Errorf(code int, errType, format string, args ...any) *ExitError {
|
||||||
var err error
|
var err error
|
||||||
for _, arg := range args {
|
for _, arg := range args {
|
||||||
@@ -117,42 +103,26 @@ func Errorf(code int, errType, format string, args ...any) *ExitError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ErrValidation creates a validation ExitError (exit 2, wire type
|
// ErrValidation creates a validation ExitError (exit 2, wire type
|
||||||
// "validation"). The legacy *output.ExitError envelope emits only
|
// "validation"). The legacy envelope emits only `type`+`message`; for
|
||||||
// `type`+`message` — no `subtype`/`param` extension fields.
|
// `subtype` / `param` extension fields, construct a typed
|
||||||
//
|
// *errs.ValidationError directly.
|
||||||
// Stage-1 status: still acceptable to use in new code that only needs the
|
|
||||||
// (type, message) pair. To carry extension fields (Subtype, Param, etc.)
|
|
||||||
// on the wire, construct `&errs.ValidationError{...}` directly so
|
|
||||||
// cmd/root.go routes it through the typed envelope writer. Per-domain
|
|
||||||
// typed migration in stage 2+ will migrate existing call sites and
|
|
||||||
// remove this helper.
|
|
||||||
func ErrValidation(format string, args ...any) *ExitError {
|
func ErrValidation(format string, args ...any) *ExitError {
|
||||||
return Errorf(ExitValidation, "validation", format, args...)
|
return Errorf(ExitValidation, "validation", format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrAuth creates an authentication ExitError (exit 3, wire type "auth").
|
// ErrAuth creates an authentication ExitError (exit 3, wire type "auth").
|
||||||
//
|
//
|
||||||
// Stage-1 status: kept as the canonical helper for token-missing /
|
// New code should construct a typed *errs.AuthenticationError directly;
|
||||||
// login-required errors, so the 19 existing call sites in cmd/auth,
|
// the typed envelope emits the canonical `type: "authentication"`.
|
||||||
// cmd/config, cmd/event, internal/client, and shortcuts/common keep
|
// Migrating an existing call site flips a user-visible wire field.
|
||||||
// emitting `type: "auth"`. To migrate a single call site to the typed
|
|
||||||
// taxonomy (`type: "authentication"` on the wire), construct
|
|
||||||
// `&errs.AuthenticationError{...}` directly — but note that flips a
|
|
||||||
// user-visible wire field and belongs in the per-domain stage-2 PR for
|
|
||||||
// that area, not in unrelated new code.
|
|
||||||
func ErrAuth(format string, args ...any) *ExitError {
|
func ErrAuth(format string, args ...any) *ExitError {
|
||||||
return Errorf(ExitAuth, "auth", format, args...)
|
return Errorf(ExitAuth, "auth", format, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrNetwork creates a network ExitError (exit 4, wire type "network").
|
// ErrNetwork creates a network ExitError (exit 4, wire type "network").
|
||||||
// The legacy *output.ExitError envelope emits only `type`+`message` — no
|
// The legacy envelope emits only `type`+`message`; for `subtype`
|
||||||
// `subtype`/`cause` extension fields.
|
// ("transport" / "timeout" / "tls" / "dns") and retryable hint extension
|
||||||
//
|
// fields, construct a typed *errs.NetworkError directly.
|
||||||
// Stage-1 status: still acceptable to use in new code that only needs the
|
|
||||||
// (type, message) pair. To carry extension fields (Subtype "transport" /
|
|
||||||
// "timeout" / "tls" / "dns", retryable hint, etc.) on the wire, construct
|
|
||||||
// `&errs.NetworkError{...}` directly. Per-domain typed migration in
|
|
||||||
// stage 2+ will migrate existing call sites and remove this helper.
|
|
||||||
func ErrNetwork(format string, args ...any) *ExitError {
|
func ErrNetwork(format string, args ...any) *ExitError {
|
||||||
return Errorf(ExitNetwork, "network", format, args...)
|
return Errorf(ExitNetwork, "network", format, args...)
|
||||||
}
|
}
|
||||||
@@ -160,14 +130,9 @@ func ErrNetwork(format string, args ...any) *ExitError {
|
|||||||
// ErrAPI creates an API ExitError using ClassifyLarkError.
|
// ErrAPI creates an API ExitError using ClassifyLarkError.
|
||||||
// For permission errors, uses a concise message; the raw API response is preserved in Detail.
|
// For permission errors, uses a concise message; the raw API response is preserved in Detail.
|
||||||
//
|
//
|
||||||
// Deprecated: ErrAPI belongs to the legacy *output.ExitError surface that
|
// Deprecated: route through errclass.BuildAPIError, which emits typed
|
||||||
// predates the typed error contract introduced by errs/. New code SHOULD
|
// *errs.PermissionError / *errs.AuthenticationError / etc. with
|
||||||
// construct a typed *errs.XxxError directly. The stage-2+ migration will
|
// MissingScopes, ConsoleURL, and Identity at the source.
|
||||||
// route classification through internal/errclass.BuildAPIError (shipped
|
|
||||||
// but not yet invoked from production paths) so the typed envelope carries
|
|
||||||
// Category, Subtype, MissingScopes, ConsoleURL, and Identity from the
|
|
||||||
// source. This helper is retained only while existing call sites are
|
|
||||||
// migrated; it will be removed once they have moved to the typed surface.
|
|
||||||
func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
||||||
exitCode, errType, hint := ClassifyLarkError(larkCode, msg)
|
exitCode, errType, hint := ClassifyLarkError(larkCode, msg)
|
||||||
if errType == "permission" {
|
if errType == "permission" {
|
||||||
@@ -187,12 +152,8 @@ func ErrAPI(larkCode int, msg string, detail any) *ExitError {
|
|||||||
|
|
||||||
// ErrWithHint creates an ExitError with a hint string.
|
// ErrWithHint creates an ExitError with a hint string.
|
||||||
//
|
//
|
||||||
// Deprecated: ErrWithHint belongs to the legacy *output.ExitError surface
|
// Deprecated: construct a typed *errs.XxxError directly and set its Hint
|
||||||
// that predates the typed error contract introduced by errs/. New code MUST
|
// field; the typed envelope promotes Problem.Hint to the wire.
|
||||||
// NOT use it — construct a typed *errs.XxxError directly and set its Hint
|
|
||||||
// field (the typed envelope promotes Problem.Hint to the wire). This helper
|
|
||||||
// is retained only while existing call sites are migrated; it will be
|
|
||||||
// removed once they have moved to the typed surface.
|
|
||||||
func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
||||||
return &ExitError{
|
return &ExitError{
|
||||||
Code: code,
|
Code: code,
|
||||||
@@ -201,15 +162,10 @@ func ErrWithHint(code int, errType, msg, hint string) *ExitError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ErrBare creates an ExitError with only an exit code and no envelope.
|
// ErrBare creates an ExitError with only an exit code and no envelope.
|
||||||
// Used for cases like `auth check` where the JSON output is already written to stdout.
|
// The predicate-command silent-exit signal: stdout has already been
|
||||||
//
|
// written and the caller wants the matching exit code without a stderr
|
||||||
// Deprecated: ErrBare belongs to the legacy *output.ExitError surface that
|
// envelope (e.g. `auth check` emitting its JSON result and then exiting
|
||||||
// predates the typed error contract introduced by errs/. New code MUST NOT
|
// non-zero on a no-token state). Outside the typed-envelope contract.
|
||||||
// use it — express the "exit with code, emit no envelope" semantics
|
|
||||||
// explicitly at the call site (e.g. return a typed *errs.XxxError or call
|
|
||||||
// os.Exit directly from RunE). This helper is retained only while existing
|
|
||||||
// call sites are migrated; it will be removed once they have moved to the
|
|
||||||
// typed surface.
|
|
||||||
func ErrBare(code int) *ExitError {
|
func ErrBare(code int) *ExitError {
|
||||||
return &ExitError{Code: code}
|
return &ExitError{Code: code}
|
||||||
}
|
}
|
||||||
@@ -220,8 +176,21 @@ func ErrBare(code int) *ExitError {
|
|||||||
// (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside
|
// (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside
|
||||||
// a `detail` sub-object.
|
// a `detail` sub-object.
|
||||||
//
|
//
|
||||||
// Returns true when err was a typed error (envelope written) and false when
|
// Two-stage write:
|
||||||
// err had no Problem (caller should fall back to WriteErrorEnvelope).
|
//
|
||||||
|
// 1. Serialize the envelope into an in-memory buffer. If serialization
|
||||||
|
// fails, return false so the dispatcher falls back to the legacy
|
||||||
|
// envelope path; nothing is written to w.
|
||||||
|
// 2. Best-effort write of the serialized bytes to w. A partial write is
|
||||||
|
// accepted (return value still true): the typed exit code has already
|
||||||
|
// been determined upstream by handleRootError calling ExitCodeOf(err)
|
||||||
|
// before this writer runs, so a torn envelope on stderr must not
|
||||||
|
// downgrade the caller's typed exit (3/4/6/10) to plain 1. Consumers
|
||||||
|
// parse-or-skip on malformed JSON.
|
||||||
|
//
|
||||||
|
// Returns true when err was a typed error and serialization succeeded.
|
||||||
|
// Returns false only when err carries no Problem (caller should fall back
|
||||||
|
// to WriteErrorEnvelope) or when JSON encoding itself failed.
|
||||||
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
||||||
typed, ok := errs.UnwrapTypedError(err)
|
typed, ok := errs.UnwrapTypedError(err)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -242,12 +211,11 @@ func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
|||||||
// back to the legacy envelope writer so stderr is never blank.
|
// back to the legacy envelope writer so stderr is never blank.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if _, writeErr := buf.WriteTo(w); writeErr != nil {
|
// Best-effort write. Partial-write does not downgrade the success status:
|
||||||
// Write failed mid-envelope. Return false so the dispatcher does
|
// the dispatcher has already captured ExitCodeOf(err) before calling us,
|
||||||
// not silently treat a half-written stderr as a successful emit
|
// and a torn stderr is preferable to falling through to the plain
|
||||||
// and skip every other fallback.
|
// "Error:" path with exit 1.
|
||||||
return false
|
_, _ = w.Write(buf.Bytes())
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,47 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/errs"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// failingWriter writes up to limit bytes then returns io.ErrShortWrite on
|
||||||
|
// the write that would push past the limit. Used to simulate a stderr that
|
||||||
|
// dies mid-envelope.
|
||||||
|
type failingWriter struct {
|
||||||
|
limit int
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *failingWriter) Write(p []byte) (int, error) {
|
||||||
|
if f.n+len(p) > f.limit {
|
||||||
|
canWrite := f.limit - f.n
|
||||||
|
if canWrite < 0 {
|
||||||
|
canWrite = 0
|
||||||
|
}
|
||||||
|
f.n += canWrite
|
||||||
|
return canWrite, io.ErrShortWrite
|
||||||
|
}
|
||||||
|
f.n += len(p)
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus pins that
|
||||||
|
// when serialization succeeds but the underlying write fails mid-envelope,
|
||||||
|
// WriteTypedErrorEnvelope returns true so the dispatcher does NOT fall
|
||||||
|
// through to the legacy "Error:" path and clobber the typed exit code with
|
||||||
|
// 1. Exit code is preserved separately by handleRootError computing
|
||||||
|
// ExitCodeOf(err) before the write.
|
||||||
|
func TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus(t *testing.T) {
|
||||||
|
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
|
||||||
|
w := &failingWriter{limit: 20} // dies mid-envelope
|
||||||
|
if ok := WriteTypedErrorEnvelope(w, err, "user"); !ok {
|
||||||
|
t.Error("partial write must return true; exit code is preserved separately")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
|
func TestWriteErrorEnvelope_WithNotice(t *testing.T) {
|
||||||
// Set up PendingNotice
|
// Set up PendingNotice
|
||||||
origNotice := PendingNotice
|
origNotice := PendingNotice
|
||||||
@@ -119,11 +157,11 @@ func TestGetNotice(t *testing.T) {
|
|||||||
PendingNotice = origNotice
|
PendingNotice = origNotice
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestErrValidation_LegacyExitErrorShape pins the stage-1 wire contract for
|
// TestErrValidation_LegacyExitErrorShape pins the wire contract for
|
||||||
// output.ErrValidation: the helper MUST return *output.ExitError (so callers
|
// output.ErrValidation: the helper MUST return *output.ExitError (so
|
||||||
// using errors.As(&exitErr) continue to work), with wire fields restricted
|
// callers using errors.As(&exitErr) continue to work), with wire fields
|
||||||
// to type+message — no `subtype` emission. The typed envelope shape (which
|
// restricted to type+message — no `subtype` emission. Typed
|
||||||
// adds subtype, param, etc.) is reserved for stage-2 per-domain migration.
|
// *errs.ValidationError carries the extension fields when needed.
|
||||||
func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
|
func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
|
||||||
err := ErrValidation("bad arg: %s", "x")
|
err := ErrValidation("bad arg: %s", "x")
|
||||||
|
|
||||||
@@ -163,7 +201,7 @@ func TestErrValidation_LegacyExitErrorShape(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestErrNetwork_LegacyExitErrorShape pins the stage-1 wire contract for
|
// TestErrNetwork_LegacyExitErrorShape pins the wire contract for
|
||||||
// output.ErrNetwork: same legacy *output.ExitError shape as ErrValidation —
|
// output.ErrNetwork: same legacy *output.ExitError shape as ErrValidation —
|
||||||
// no subtype field, errors.As(&exitErr) must succeed, exit code ExitNetwork.
|
// no subtype field, errors.As(&exitErr) must succeed, exit code ExitNetwork.
|
||||||
func TestErrNetwork_LegacyExitErrorShape(t *testing.T) {
|
func TestErrNetwork_LegacyExitErrorShape(t *testing.T) {
|
||||||
|
|||||||
@@ -31,10 +31,15 @@ const (
|
|||||||
LarkErrUserNotAuthorized = 230027 // user not authorized
|
LarkErrUserNotAuthorized = 230027 // user not authorized
|
||||||
|
|
||||||
// App credential / status.
|
// App credential / status.
|
||||||
LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect
|
LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect (Open API)
|
||||||
LarkErrAppNotInUse = 99991662 // app is disabled or not installed in this tenant
|
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
|
||||||
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
|
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
|
||||||
|
|
||||||
|
// TAT-endpoint variant of the "wrong app credentials" condition.
|
||||||
|
// /open-apis/auth/v3/tenant_access_token/internal returns code 10014
|
||||||
|
// ("app secret invalid") instead of 99991543 when the secret is wrong.
|
||||||
|
LarkErrTATInvalidSecret = 10014
|
||||||
|
|
||||||
// Rate limit.
|
// Rate limit.
|
||||||
LarkErrRateLimit = 99991400 // request frequency limit exceeded
|
LarkErrRateLimit = 99991400 // request frequency limit exceeded
|
||||||
|
|
||||||
@@ -94,14 +99,15 @@ var legacyHints = map[int]string{
|
|||||||
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
|
LarkErrATInvalid: "run: lark-cli auth login to re-authorize",
|
||||||
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
|
LarkErrTokenExpired: "run: lark-cli auth login to re-authorize",
|
||||||
|
|
||||||
LarkErrAppScopeNotEnabled: "check app permissions or re-authorize: lark-cli auth login",
|
LarkErrAppScopeNotEnabled: "the app developer must apply for the required scope(s) at the developer console",
|
||||||
LarkErrTokenNoPermission: "check app permissions or re-authorize: lark-cli auth login",
|
LarkErrTokenNoPermission: "check the token's granted scopes; run `lark-cli auth login` to refresh if the scope was added after the token was issued",
|
||||||
LarkErrUserScopeInsufficient: "check app permissions or re-authorize: lark-cli auth login",
|
LarkErrUserScopeInsufficient: "run `lark-cli auth login` to re-authorize the user with the updated scope set",
|
||||||
LarkErrUserNotAuthorized: "check app permissions or re-authorize: lark-cli auth login",
|
LarkErrUserNotAuthorized: "run `lark-cli auth login` to re-authorize this user; if re-auth does not help, the operation may be blocked by external-chat or admin policy",
|
||||||
|
|
||||||
LarkErrAppCredInvalid: "check app_id / app_secret: lark-cli config set",
|
LarkErrAppCredInvalid: "run `lark-cli config init` to set valid app_id and app_secret",
|
||||||
LarkErrAppNotInUse: "app is disabled or not installed — check developer console",
|
LarkErrTATInvalidSecret: "run `lark-cli config init` to set valid app_id and app_secret",
|
||||||
LarkErrAppUnauthorized: "app is disabled or not installed — check developer console",
|
LarkErrAppNotInUse: "ask the tenant admin to re-enable the app in the Lark admin console",
|
||||||
|
LarkErrAppUnauthorized: "ask the tenant admin to check the app's install status in the Lark admin console",
|
||||||
|
|
||||||
LarkErrRateLimit: "please try again later",
|
LarkErrRateLimit: "please try again later",
|
||||||
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
|
LarkErrDriveResourceContention: "please retry later and avoid concurrent duplicate requests",
|
||||||
@@ -117,32 +123,18 @@ var legacyHints = map[int]string{
|
|||||||
// ClassifyLarkError maps a Lark API error code + message to the legacy
|
// ClassifyLarkError maps a Lark API error code + message to the legacy
|
||||||
// (exitCode, errType, hint) tuple consumed by the *ExitError path.
|
// (exitCode, errType, hint) tuple consumed by the *ExitError path.
|
||||||
//
|
//
|
||||||
// Classification (Category / Subtype) is sourced from
|
// Classification is sourced from errclass.LookupCodeMeta (the single source
|
||||||
// errclass.LookupCodeMeta — the single source of truth shipped for both
|
// of truth). exitCode follows legacyExitCode below, which differs from
|
||||||
// this legacy adapter and the stage-2+ typed pipeline (errclass.BuildAPIError,
|
// ExitCodeForCategory in two preserved-legacy quirks: Authorization +
|
||||||
// not yet invoked in production). This function adapts that result back to
|
// permission subtypes return ExitAPI (legacy treated "permission" as
|
||||||
// the legacy tuple shape for callers that still go through *ExitError:
|
// exit 1), and Config returns ExitAuth (legacy bundled "check
|
||||||
|
// app_id/secret" under exit 3). errType maps to a legacy short string;
|
||||||
|
// unknown subtypes fall back to "api_error". Unknown codes classify as
|
||||||
|
// (ExitAPI, "api_error", "").
|
||||||
//
|
//
|
||||||
// - exitCode: derived from (Category, Subtype) via legacyExitCode below.
|
// Deprecated: route Lark API responses through errclass.BuildAPIError,
|
||||||
// Note this differs from the typed pipeline's ExitCodeForCategory in
|
// which emits a typed *errs.XxxError with Category, Subtype, and
|
||||||
// two preserved-legacy-quirks: Authorization+permission subtypes return
|
// identity-aware extension fields populated at the source.
|
||||||
// ExitAPI (legacy treats "permission" as exit 1) and Config returns
|
|
||||||
// ExitAuth (legacy bundles "check app_id/secret" under exit 3).
|
|
||||||
// - errType: legacy short string per (Category, Subtype), mapped by
|
|
||||||
// legacyErrType. Subtypes not present in the legacy taxonomy fall back
|
|
||||||
// to "api_error".
|
|
||||||
// - hint: per-code lookup in legacyHints; "" when absent.
|
|
||||||
//
|
|
||||||
// Unknown codes (LookupCodeMeta returns false) classify as
|
|
||||||
// (ExitAPI, "api_error", "") — matching the prior default.
|
|
||||||
//
|
|
||||||
// Deprecated: ClassifyLarkError belongs to the legacy *output.ExitError
|
|
||||||
// surface that predates the typed error contract introduced by errs/. New
|
|
||||||
// code MUST NOT use it — classify Lark API responses via
|
|
||||||
// internal/errclass.BuildAPIError, which emits a typed *errs.XxxError with
|
|
||||||
// Category, Subtype, and identity-aware extension fields populated at the
|
|
||||||
// source. This helper is retained only while existing call sites are
|
|
||||||
// migrated; it will be removed once they have moved to the typed surface.
|
|
||||||
func ClassifyLarkError(code int, msg string) (int, string, string) {
|
func ClassifyLarkError(code int, msg string) (int, string, string) {
|
||||||
meta, ok := errclass.LookupCodeMeta(code)
|
meta, ok := errclass.LookupCodeMeta(code)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -180,7 +172,7 @@ func legacyExitCode(cat errs.Category, sub errs.Subtype) int {
|
|||||||
errs.SubtypeTokenScopeInsufficient:
|
errs.SubtypeTokenScopeInsufficient:
|
||||||
return ExitAPI
|
return ExitAPI
|
||||||
case errs.SubtypeAppUnavailable,
|
case errs.SubtypeAppUnavailable,
|
||||||
errs.SubtypeAppNotInstalled:
|
errs.SubtypeAppDisabled:
|
||||||
return ExitAuth
|
return ExitAuth
|
||||||
}
|
}
|
||||||
return ExitAPI
|
return ExitAPI
|
||||||
@@ -206,7 +198,7 @@ func legacyErrType(cat errs.Category, sub errs.Subtype) string {
|
|||||||
errs.SubtypeTokenScopeInsufficient:
|
errs.SubtypeTokenScopeInsufficient:
|
||||||
return "permission"
|
return "permission"
|
||||||
case errs.SubtypeAppUnavailable,
|
case errs.SubtypeAppUnavailable,
|
||||||
errs.SubtypeAppNotInstalled:
|
errs.SubtypeAppDisabled:
|
||||||
return "app_status"
|
return "app_status"
|
||||||
}
|
}
|
||||||
return "permission"
|
return "permission"
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const (
|
|||||||
ReasonDuplicateHookName = "duplicate_hook_name"
|
ReasonDuplicateHookName = "duplicate_hook_name"
|
||||||
ReasonInvalidHookRegister = "invalid_hook_registration"
|
ReasonInvalidHookRegister = "invalid_hook_registration"
|
||||||
ReasonInvalidRule = "invalid_rule"
|
ReasonInvalidRule = "invalid_rule"
|
||||||
ReasonDoubleRestrict = "double_restrict"
|
|
||||||
ReasonRestrictsMismatch = "restricts_mismatch"
|
ReasonRestrictsMismatch = "restricts_mismatch"
|
||||||
ReasonCapabilityUnmet = "capability_unmet"
|
ReasonCapabilityUnmet = "capability_unmet"
|
||||||
ReasonCapabilitiesPanic = "capabilities_panic"
|
ReasonCapabilitiesPanic = "capabilities_panic"
|
||||||
|
|||||||
@@ -201,10 +201,10 @@ func installOne(name string, p platform.Plugin, result *InstallResult) error {
|
|||||||
for _, e := range staging.stagedLifecycles {
|
for _, e := range staging.stagedLifecycles {
|
||||||
result.Registry.AddLifecycle(e)
|
result.Registry.AddLifecycle(e)
|
||||||
}
|
}
|
||||||
if staging.rule != nil {
|
for _, rule := range staging.rules {
|
||||||
result.PluginRules = append(result.PluginRules, cmdpolicy.PluginRule{
|
result.PluginRules = append(result.PluginRules, cmdpolicy.PluginRule{
|
||||||
PluginName: name,
|
PluginName: name,
|
||||||
Rule: staging.rule,
|
Rule: rule,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -389,3 +389,45 @@ func TestInstallAll_atomicRollback(t *testing.T) {
|
|||||||
t.Fatalf("error must be *PluginInstallError, got %T", err)
|
t.Fatalf("error must be *PluginInstallError, got %T", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// multiRestrictPlugin calls r.Restrict twice -- the multi-rule case. A
|
||||||
|
// single plugin may declare several scoped grants; both must be collected
|
||||||
|
// into PluginRules under the same plugin name, in registration order.
|
||||||
|
type multiRestrictPlugin struct{}
|
||||||
|
|
||||||
|
func (multiRestrictPlugin) Name() string { return "secaudit" }
|
||||||
|
func (multiRestrictPlugin) Version() string { return "1.0.0" }
|
||||||
|
func (multiRestrictPlugin) Capabilities() platform.Capabilities {
|
||||||
|
return platform.Capabilities{
|
||||||
|
Restricts: true,
|
||||||
|
FailurePolicy: platform.FailClosed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (multiRestrictPlugin) Install(r platform.Registrar) error {
|
||||||
|
r.Restrict(&platform.Rule{Name: "docs-ro", Allow: []string{"docs/**"}, MaxRisk: platform.RiskRead})
|
||||||
|
r.Restrict(&platform.Rule{Name: "im-rw", Allow: []string{"im/**"}, MaxRisk: platform.RiskWrite})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A single plugin calling Restrict more than once is valid (multi-rule
|
||||||
|
// support): both rules are collected, in order, under the one plugin name.
|
||||||
|
// This pins the behaviour change from the old "Restrict at most once"
|
||||||
|
// double_restrict error.
|
||||||
|
func TestInstallAll_multipleRestrictPerPlugin(t *testing.T) {
|
||||||
|
result, err := internalplatform.InstallAll([]platform.Plugin{multiRestrictPlugin{}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("multiple Restrict per plugin must succeed, got %v", err)
|
||||||
|
}
|
||||||
|
if len(result.PluginRules) != 2 {
|
||||||
|
t.Fatalf("PluginRules = %d, want 2", len(result.PluginRules))
|
||||||
|
}
|
||||||
|
for _, pr := range result.PluginRules {
|
||||||
|
if pr.PluginName != "secaudit" {
|
||||||
|
t.Errorf("PluginName = %q, want secaudit", pr.PluginName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.PluginRules[0].Rule.Name != "docs-ro" || result.PluginRules[1].Rule.Name != "im-rw" {
|
||||||
|
t.Errorf("rules out of order: %q, %q",
|
||||||
|
result.PluginRules[0].Rule.Name, result.PluginRules[1].Rule.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user