mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Replace every command-facing error path in the event domain — the consume/schema command layer, the +subscribe shortcut, EventKey definitions, and the consume orchestration — with typed errs.* envelopes, so consumers get stable type, subtype, param, hint, and missing_scopes metadata for classification and recovery instead of free-form message text. - Input validation (--jq, --param, --output-dir, --filter, --route, unknown EventKey, EventKey params) reports validation / invalid_argument with the offending flag in param and an actionable hint. - Scope preflight reports authorization / missing_scope with the machine-readable missing_scopes list; console-subscription and single-bus preconditions report failed_precondition with recovery hints. - The consume API boundary passes already-typed errors through and classifies transport, non-JSON HTTP, and unparsable responses; the vc note-detail retry now matches the not-found code on typed errors (it silently never fired against the legacy envelope shape). - Previously-bare failures exited 1 with a plain-text "Error:" line and now exit with their category code (validation 2, auth 3, network 4, internal 5) alongside the typed stderr envelope. - forbidigo and errscontract guards now cover the event paths so regressions fail lint; AGENTS.md and the lark-event skill document the typed contract for agent consumers. Validation: make unit-test (race) green; event unit and e2e suites assert category/subtype/param/hint and cause preservation against the real binary; errscontract and golangci lint clean.
222 lines
6.0 KiB
Go
222 lines
6.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package event
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/credential"
|
|
)
|
|
|
|
func TestParseParams(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in []string
|
|
want map[string]string
|
|
wantSentry error
|
|
wantEcho string
|
|
}{
|
|
{
|
|
name: "empty input",
|
|
in: nil,
|
|
want: map[string]string{},
|
|
},
|
|
{
|
|
name: "single key=value",
|
|
in: []string{"mailbox=user@example.com"},
|
|
want: map[string]string{"mailbox": "user@example.com"},
|
|
},
|
|
{
|
|
name: "multiple pairs",
|
|
in: []string{"a=1", "b=2", "c=3"},
|
|
want: map[string]string{"a": "1", "b": "2", "c": "3"},
|
|
},
|
|
{
|
|
name: "value containing = is kept intact",
|
|
in: []string{"filter=foo=bar"},
|
|
want: map[string]string{"filter": "foo=bar"},
|
|
},
|
|
{
|
|
name: "empty value allowed",
|
|
in: []string{"key="},
|
|
want: map[string]string{"key": ""},
|
|
},
|
|
{
|
|
name: "duplicate key — last wins",
|
|
in: []string{"k=1", "k=2"},
|
|
want: map[string]string{"k": "2"},
|
|
},
|
|
{
|
|
name: "missing = separator",
|
|
in: []string{"mailbox"},
|
|
wantSentry: errInvalidParamFormat,
|
|
wantEcho: `"mailbox"`,
|
|
},
|
|
{
|
|
name: "leading = (empty key)",
|
|
in: []string{"=value"},
|
|
wantSentry: errInvalidParamFormat,
|
|
wantEcho: `"=value"`,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := parseParams(tc.in)
|
|
if tc.wantSentry != nil {
|
|
if err == nil {
|
|
t.Fatalf("want error wrapping %v, got nil", tc.wantSentry)
|
|
}
|
|
if !errors.Is(err, tc.wantSentry) {
|
|
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
|
}
|
|
if tc.wantEcho != "" && !strings.Contains(err.Error(), tc.wantEcho) {
|
|
t.Errorf("err %q should echo %q so user sees the bad input", err.Error(), tc.wantEcho)
|
|
}
|
|
assertInvalidArgumentParam(t, err, "--param")
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("len = %d, want %d; got=%v", len(got), len(tc.want), got)
|
|
}
|
|
for k, v := range tc.want {
|
|
if got[k] != v {
|
|
t.Errorf("key %q: got %q, want %q", k, got[k], v)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// emptyTokenResolver resolves to a result that carries no token.
|
|
type emptyTokenResolver struct{}
|
|
|
|
func (emptyTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
|
return &credential.TokenResult{}, nil
|
|
}
|
|
|
|
// failingTokenResolver fails outright with an untyped error.
|
|
type failingTokenResolver struct{}
|
|
|
|
func (failingTokenResolver) ResolveToken(_ context.Context, _ credential.TokenSpec) (*credential.TokenResult, error) {
|
|
return nil, errors.New("backend unavailable")
|
|
}
|
|
|
|
func factoryWithResolver(r credential.DefaultTokenResolver) *cmdutil.Factory {
|
|
return &cmdutil.Factory{Credential: credential.NewCredentialProvider(nil, nil, r, nil)}
|
|
}
|
|
|
|
func TestResolveTenantToken_EmptyTokenResult(t *testing.T) {
|
|
_, err := resolveTenantToken(context.Background(), factoryWithResolver(emptyTokenResolver{}), "cli_x")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
|
}
|
|
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
|
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
|
}
|
|
var malformed *credential.MalformedTokenResultError
|
|
if !errors.As(err, &malformed) {
|
|
t.Error("empty-token failure should preserve the credential-layer cause")
|
|
}
|
|
}
|
|
|
|
func TestResolveTenantToken_ResolverFailure(t *testing.T) {
|
|
_, err := resolveTenantToken(context.Background(), factoryWithResolver(failingTokenResolver{}), "cli_x")
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
p, ok := errs.ProblemOf(err)
|
|
if !ok {
|
|
t.Fatalf("expected typed errs error, got %T: %v", err, err)
|
|
}
|
|
if p.Category != errs.CategoryAuthentication || p.Subtype != errs.SubtypeTokenMissing {
|
|
t.Errorf("problem = %s/%s, want %s/%s", p.Category, p.Subtype,
|
|
errs.CategoryAuthentication, errs.SubtypeTokenMissing)
|
|
}
|
|
if errors.Unwrap(err) == nil {
|
|
t.Error("resolver failure should preserve its cause")
|
|
}
|
|
}
|
|
|
|
// assertInvalidArgumentParam verifies err is a typed validation error with
|
|
// subtype invalid_argument naming the given flag in its param field.
|
|
func assertInvalidArgumentParam(t *testing.T, err error, param string) {
|
|
t.Helper()
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("subtype = %s, want %s", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if ve.Param != param {
|
|
t.Errorf("param = %q, want %q", ve.Param, param)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeOutputDir(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
wantSentry error
|
|
}{
|
|
{
|
|
name: "relative path accepted",
|
|
in: "./output",
|
|
},
|
|
{
|
|
name: "nested relative path accepted",
|
|
in: "events/today",
|
|
},
|
|
{
|
|
name: "tilde rejected explicitly",
|
|
in: "~/events",
|
|
wantSentry: errOutputDirTilde,
|
|
},
|
|
{
|
|
name: "parent escape rejected",
|
|
in: "../outside",
|
|
wantSentry: errOutputDirUnsafe,
|
|
},
|
|
{
|
|
name: "absolute path rejected",
|
|
in: "/tmp/events",
|
|
wantSentry: errOutputDirUnsafe,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got, err := sanitizeOutputDir(tc.in)
|
|
if tc.wantSentry != nil {
|
|
if err == nil {
|
|
t.Fatalf("want error wrapping %v, got nil (path=%q)", tc.wantSentry, got)
|
|
}
|
|
if !errors.Is(err, tc.wantSentry) {
|
|
t.Fatalf("want errors.Is(err, %v), got %q", tc.wantSentry, err.Error())
|
|
}
|
|
assertInvalidArgumentParam(t, err, "--output-dir")
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got == "" {
|
|
t.Errorf("expected non-empty safe path, got %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|