mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
3 Commits
refactor/o
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15263efe30 | ||
|
|
5b67085b32 | ||
|
|
da149e66ba |
121
cmd/api/api.go
121
cmd/api/api.go
@@ -5,6 +5,8 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -232,15 +234,6 @@ func apiRun(opts *APIOptions) error {
|
||||
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
|
||||
)
|
||||
}
|
||||
// Parse before the dry-run branch so both dry-run and emit reject unknown
|
||||
// values. Raw API responses accept four formats; pretty remains available
|
||||
// only for the dry-run request preview handled below.
|
||||
format, ok := output.ParseFormat(opts.Format)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
|
||||
WithParam("--format")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -257,17 +250,9 @@ func apiRun(opts *APIOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts, format), *fileMeta)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return apiDryRun(f, request, config, opts, format)
|
||||
}
|
||||
// pretty is a shortcut-only presentation format; the raw api command has no
|
||||
// pretty renderer for responses, so reject it before client init rather than
|
||||
// fall back. (Dry-run keeps its own plain-text pretty preview, handled above.)
|
||||
if format == output.FormatPretty {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--format pretty is not supported here (use json, ndjson, table, or csv)").
|
||||
WithParam("--format")
|
||||
return apiDryRun(f, request, config, opts)
|
||||
}
|
||||
// Identity info is now included in the JSON envelope; skip stderr printing.
|
||||
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
|
||||
@@ -278,20 +263,14 @@ func apiRun(opts *APIOptions) error {
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
format, formatOK := output.ParseFormat(opts.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
|
||||
if opts.PageAll {
|
||||
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
|
||||
Client: ac,
|
||||
Request: request,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
|
||||
CheckErr: ac.CheckResponse,
|
||||
MarkErr: errs.MarkRaw,
|
||||
})
|
||||
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
|
||||
}
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
@@ -325,13 +304,13 @@ func apiRun(opts *APIOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions, format output.Format) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts, format))
|
||||
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions, format output.Format) cmdutil.DryRunOutputOptions {
|
||||
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: format.String(),
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
@@ -339,3 +318,75 @@ func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions, format output.For
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
if !hasItems {
|
||||
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
}
|
||||
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return errs.MarkRaw(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,23 +66,6 @@ func apiPaginateRequest() client.RawApiRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// apiPaginate adapts the positional test calls to PaginateToOutput's options
|
||||
// struct so each test case stays a single readable statement.
|
||||
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
|
||||
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
|
||||
Client: ac,
|
||||
Request: request,
|
||||
Format: format,
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Pagination: pag,
|
||||
CheckErr: checkErr,
|
||||
MarkErr: markErr,
|
||||
})
|
||||
}
|
||||
|
||||
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
@@ -129,10 +112,10 @@ func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse, errs.MarkRaw)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
@@ -212,10 +195,10 @@ func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse, errs.MarkRaw)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
@@ -256,14 +239,14 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
|
||||
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
@@ -273,7 +256,7 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
|
||||
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
@@ -288,17 +271,22 @@ func TestAPIPaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
|
||||
if got := out.String(); got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
if errOut.Len() != 0 {
|
||||
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,10 +314,10 @@ func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("PaginateToOutput() error = nil, want business error")
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
@@ -361,10 +349,10 @@ func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
|
||||
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("PaginateToOutput() error = nil, want transport error")
|
||||
t.Fatal("apiPaginate() error = nil, want transport error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
@@ -391,10 +379,10 @@ func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("PaginateToOutput() error = nil, want business error")
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
|
||||
@@ -118,101 +118,6 @@ func TestApiCmd_DryRunWithJq(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown --format is a typed validation error, not a silent JSON fallback —
|
||||
// on both the emit path and (parsed before the dry-run branch) the dry-run path.
|
||||
// No stub is registered because the command must fail before any API call.
|
||||
func TestApiCmd_UnknownFormat_Rejected(t *testing.T) {
|
||||
for _, extra := range [][]string{nil, {"--dry-run"}} {
|
||||
name := "emit"
|
||||
if len(extra) > 0 {
|
||||
name = "dry-run"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs(append([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "bogus"}, extra...))
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error for unknown --format")
|
||||
}
|
||||
requireValidationParam(t, err, "--format")
|
||||
if !strings.Contains(err.Error(), "unknown output format") {
|
||||
t.Errorf("error = %v, want unknown-format message", err)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "pretty") {
|
||||
t.Errorf("error = %v, raw api format choices must exclude pretty", err)
|
||||
}
|
||||
if stdout.String() != "" {
|
||||
t.Errorf("unknown --format must not write stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_UnknownFormatPrecedesJqConflict(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{
|
||||
"GET", "/open-apis/test", "--as", "bot",
|
||||
"--format", "tabel", "--jq", ".",
|
||||
})
|
||||
|
||||
err := cmd.Execute()
|
||||
requireValidationParam(t, err, "--format")
|
||||
if !strings.Contains(err.Error(), "unknown output format") {
|
||||
t.Fatalf("error = %v, want unknown-format message", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// pretty is shortcut-only: the raw api command rejects it on the emit path
|
||||
// (before client init) but keeps the dry-run plain-text preview.
|
||||
func TestApiCmd_Pretty_RejectedOnEmit(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error for --format pretty on the emit path")
|
||||
}
|
||||
requireValidationParam(t, err, "--format")
|
||||
if !strings.Contains(err.Error(), "pretty") {
|
||||
t.Errorf("error = %v, want pretty-not-supported message", err)
|
||||
}
|
||||
if stdout.String() != "" {
|
||||
t.Errorf("rejected --format pretty must not write stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_MixedCasePretty_PreservedOnDryRun(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestApiCmd(f, nil)
|
||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "Pretty", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run --format pretty must be accepted, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
|
||||
t.Fatalf("dry-run --format pretty lost its plain-text preview, stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: --params null parses to a nil map; writing page_size onto it must
|
||||
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
|
||||
// write into the map ParseJSONMap returns.
|
||||
@@ -499,7 +404,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_NonBatchAPI_HonorsNDJSON(t *testing.T) {
|
||||
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -522,15 +427,24 @@ func TestApiCmd_PageAll_NonBatchAPI_HonorsNDJSON(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if strings.Contains(stderr.String(), "falling back") {
|
||||
t.Fatalf("stderr contains format fallback warning: %q", stderr.String())
|
||||
// Should print fallback warning to stderr
|
||||
if !strings.Contains(stderr.String(), "warning: this API does not return a list") {
|
||||
t.Error("expected fallback warning in stderr")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "falling back to json") {
|
||||
t.Error("expected 'falling back to json' in stderr")
|
||||
}
|
||||
// Should output JSON result to stdout
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid NDJSON object: %v\n%s", err, stdout.String())
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got["user_id"] != "u123" || got["name"] != "Test User" {
|
||||
t.Fatalf("unexpected NDJSON object: %#v", got)
|
||||
data, ok := got["data"].(map[string]interface{})
|
||||
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
|
||||
t.Fatalf("unexpected fallback envelope: %#v", got)
|
||||
}
|
||||
if _, hasCode := got["code"]; hasCode {
|
||||
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -707,10 +621,6 @@ func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest
|
||||
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
|
||||
}
|
||||
|
||||
func (p *apiContentSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.Scan(ctx, req)
|
||||
}
|
||||
|
||||
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &apiContentSafetyProvider{}
|
||||
@@ -744,12 +654,12 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
if provider.path != "api" {
|
||||
t.Fatalf("scan path = %q, want api", provider.path)
|
||||
}
|
||||
data, ok := provider.data.(string)
|
||||
data, ok := provider.data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("scanned data type = %T, want rendered JSON string", provider.data)
|
||||
t.Fatalf("scanned data type = %T, want map", provider.data)
|
||||
}
|
||||
if strings.Contains(data, `"code"`) || !strings.Contains(data, `"data"`) {
|
||||
t.Fatalf("scanned JSON should be the success envelope without an API code, got %q", data)
|
||||
if _, hasCode := data["code"]; hasCode {
|
||||
t.Fatalf("scanned data should be business data only, got %#v", data)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
@@ -795,11 +705,9 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
if provider.path != "api" {
|
||||
t.Fatalf("scan path = %q, want api", provider.path)
|
||||
}
|
||||
// Streaming now scans the exact rendered page bytes (not the structured
|
||||
// item) so a rule match formed only in the rendered output cannot slip past.
|
||||
scanned, ok := provider.data.(string)
|
||||
if !ok || !strings.Contains(scanned, `"id":"1"`) {
|
||||
t.Fatalf("scanned data = %#v, want rendered ndjson page text", provider.data)
|
||||
items, ok := provider.data.([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
|
||||
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
|
||||
@@ -859,8 +767,11 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
|
||||
}
|
||||
out := stdout.String()
|
||||
if out != "" {
|
||||
t.Fatalf("blocked complete stream was written before safety block: %s", out)
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "blocked-page") {
|
||||
t.Fatalf("blocked page was written before safety block: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -875,18 +786,6 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err
|
||||
}
|
||||
}
|
||||
|
||||
func requireValidationParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != param {
|
||||
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
|
||||
@@ -381,34 +381,6 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesCmd_RejectsUnknownFormat(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
runCalled := false
|
||||
cmd := NewCmdAuthScopes(f, func(*ScopesOptions) error {
|
||||
runCalled = true
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs([]string{"--format", "tabel"})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid format error")
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("error = %T, want *errs.ValidationError", err)
|
||||
}
|
||||
if validationErr.Category != errs.CategoryValidation || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
|
||||
t.Fatalf("validation error = %#v; want validation/invalid_argument with --format", validationErr)
|
||||
}
|
||||
if runCalled {
|
||||
t.Fatal("auth scopes runner was called for an invalid format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,
|
||||
|
||||
@@ -6,8 +6,6 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -35,13 +33,6 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
|
||||
opts.Ctx = cmd.Context()
|
||||
if opts.JSON {
|
||||
opts.Format = "json"
|
||||
} else {
|
||||
opts.Format = strings.ToLower(strings.TrimSpace(opts.Format))
|
||||
if opts.Format != "json" && opts.Format != "pretty" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown output format %q (want json or pretty)", opts.Format).
|
||||
WithParam("--format")
|
||||
}
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
@@ -83,36 +74,20 @@ func authScopesRun(opts *ScopesOptions) error {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError,
|
||||
"failed to get app scope info: %v", err).WithCause(err)
|
||||
}
|
||||
data := map[string]interface{}{
|
||||
"appId": config.AppID,
|
||||
"brand": config.Brand,
|
||||
"tokenType": "user",
|
||||
"userScopes": appInfo.UserScopes,
|
||||
"count": len(appInfo.UserScopes),
|
||||
}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: f.IOStreams.Out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
CommandPath: "lark-cli auth scopes",
|
||||
})
|
||||
if opts.Format == "pretty" {
|
||||
return emitter.Value(data, output.StreamOptions{
|
||||
Format: output.FormatPretty,
|
||||
Pretty: func(w io.Writer, _ bool) error {
|
||||
if _, err := fmt.Fprintf(w, "App ID: %s\n", config.AppID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, scope := range appInfo.UserScopes {
|
||||
if _, err := fmt.Fprintf(w, " • %s\n", scope); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
|
||||
for _, s := range appInfo.UserScopes {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, " • %s\n", s)
|
||||
}
|
||||
} else {
|
||||
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
|
||||
"appId": config.AppID,
|
||||
"brand": config.Brand,
|
||||
"tokenType": "user",
|
||||
"userScopes": appInfo.UserScopes,
|
||||
"count": len(appInfo.UserScopes),
|
||||
})
|
||||
}
|
||||
return emitter.Value(data, output.StreamOptions{Format: output.FormatJSON})
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -44,36 +43,6 @@ func scopesTestFactory(t *testing.T) *ScopesOptions {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthScopesRunPrettyWritesBusinessDataToStdout(t *testing.T) {
|
||||
previous := getAppInfoFn
|
||||
getAppInfoFn = func(context.Context, *cmdutil.Factory, string) (*appInfo, error) {
|
||||
return &appInfo{UserScopes: []string{"im:message"}}, nil
|
||||
}
|
||||
t.Cleanup(func() { getAppInfoFn = previous })
|
||||
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
})
|
||||
err := authScopesRun(&ScopesOptions{
|
||||
Factory: f,
|
||||
Ctx: context.Background(),
|
||||
Format: "pretty",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("authScopesRun() error = %v", err)
|
||||
}
|
||||
for _, want := range []string{"App ID: test-app", "Enabled scopes (1)", "im:message"} {
|
||||
if !strings.Contains(stdout.String(), want) {
|
||||
t.Fatalf("stdout missing %q: %s", want, stdout.String())
|
||||
}
|
||||
if strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("stderr contains business data %q: %s", want, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -6,6 +6,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -379,15 +380,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
if opts.PageAll && opts.Output != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output")
|
||||
}
|
||||
// Parse before the dry-run branch so both dry-run and emit reject unknown
|
||||
// values. Raw service responses accept four formats; pretty remains available
|
||||
// only for the dry-run request preview handled below.
|
||||
format, ok := output.ParseFormat(opts.Format)
|
||||
if !ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
|
||||
WithParam("--format")
|
||||
}
|
||||
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -411,18 +403,9 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
|
||||
if opts.DryRun {
|
||||
if fileMeta != nil {
|
||||
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts, format), *fileMeta)
|
||||
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
|
||||
}
|
||||
return serviceDryRun(f, request, config, opts, format)
|
||||
}
|
||||
// pretty is a shortcut-only presentation format; the raw service command has
|
||||
// no pretty renderer for responses, so reject it before the confirmation and
|
||||
// client init rather than fall back. (Dry-run keeps its own plain-text pretty
|
||||
// preview, handled above.)
|
||||
if format == output.FormatPretty {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--format pretty is not supported here (use json, ndjson, table, or csv)").
|
||||
WithParam("--format")
|
||||
return serviceDryRun(f, request, config, opts)
|
||||
}
|
||||
|
||||
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
|
||||
@@ -437,6 +420,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
}
|
||||
|
||||
out := f.IOStreams.Out
|
||||
format, formatOK := output.ParseFormat(opts.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
|
||||
// Scope-insufficient (99991679) and all other Lark API codes route through
|
||||
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
|
||||
@@ -444,18 +431,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
|
||||
checkErr := ac.CheckResponse
|
||||
|
||||
if opts.PageAll {
|
||||
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
|
||||
Client: ac,
|
||||
Request: request,
|
||||
Format: format,
|
||||
JqExpr: opts.JqExpr,
|
||||
Out: out,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
|
||||
CheckErr: checkErr,
|
||||
MarkErr: nil,
|
||||
})
|
||||
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
|
||||
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
|
||||
}
|
||||
|
||||
resp, err := ac.DoAPI(opts.Ctx, request)
|
||||
@@ -690,13 +667,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
|
||||
return request, nil, nil
|
||||
}
|
||||
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions, format output.Format) error {
|
||||
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts, format))
|
||||
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
|
||||
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
|
||||
}
|
||||
|
||||
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions, format output.Format) cmdutil.DryRunOutputOptions {
|
||||
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
|
||||
return cmdutil.DryRunOutputOptions{
|
||||
Format: format.String(),
|
||||
Format: opts.Format,
|
||||
JqExpr: opts.JqExpr,
|
||||
CommandPath: opts.Cmd.CommandPath(),
|
||||
Identity: opts.As,
|
||||
@@ -704,3 +681,75 @@ func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions,
|
||||
ErrOut: f.IOStreams.ErrOut,
|
||||
}
|
||||
}
|
||||
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
// Streaming formats intentionally emit each page after that page has
|
||||
// passed safety scanning. A later page may still fail, so callers
|
||||
// must use the exit code to distinguish complete vs partial output.
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
return apiErr
|
||||
}
|
||||
if !hasItems {
|
||||
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
output.FormatValue(out, result, output.FormatJSON)
|
||||
return apiErr
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,23 +66,6 @@ func servicePaginateRequest() client.RawApiRequest {
|
||||
}
|
||||
}
|
||||
|
||||
// servicePaginate adapts the positional test calls to PaginateToOutput's options
|
||||
// struct so each test case stays a single readable statement.
|
||||
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
|
||||
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
|
||||
Client: ac,
|
||||
Request: request,
|
||||
Format: format,
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Pagination: pag,
|
||||
CheckErr: checkErr,
|
||||
MarkErr: markErr,
|
||||
})
|
||||
}
|
||||
|
||||
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
@@ -129,10 +112,10 @@ func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse, nil)
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
@@ -212,10 +195,10 @@ func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse, nil)
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if got := out.String(); got != tt.want {
|
||||
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
|
||||
@@ -256,14 +239,14 @@ func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, nil)
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
|
||||
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
@@ -273,7 +256,7 @@ func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
|
||||
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
@@ -289,17 +272,22 @@ func TestServicePaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
|
||||
if got := out.String(); got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
if errOut.Len() != 0 {
|
||||
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
})
|
||||
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
|
||||
if got := errOut.String(); got != wantWarning {
|
||||
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,10 +316,10 @@ func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("PaginateToOutput() error = nil, want business error")
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
@@ -364,10 +352,10 @@ func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("PaginateToOutput() error = nil, want transport error")
|
||||
t.Fatal("servicePaginate() error = nil, want transport error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
@@ -395,10 +383,10 @@ func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("PaginateToOutput() error = nil, want business error")
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
|
||||
@@ -257,21 +257,6 @@ func TestServiceMethod_DryRunWithJq(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_DryRunMixedCasePrettyUsesPlainTextPreview(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--dry-run", "--format", "Pretty"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run --format Pretty must be accepted, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
|
||||
t.Fatalf("dry-run --format Pretty lost its plain-text preview, stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -540,10 +525,6 @@ func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanReq
|
||||
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
|
||||
}
|
||||
|
||||
func (p *serviceContentSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.Scan(ctx, req)
|
||||
}
|
||||
|
||||
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
provider := &serviceContentSafetyProvider{}
|
||||
@@ -580,12 +561,12 @@ func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
||||
if provider.path != "list" {
|
||||
t.Fatalf("scan path = %q, want list", provider.path)
|
||||
}
|
||||
data, ok := provider.data.(string)
|
||||
data, ok := provider.data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("scanned data type = %T, want rendered JSON string", provider.data)
|
||||
t.Fatalf("scanned data type = %T, want map", provider.data)
|
||||
}
|
||||
if strings.Contains(data, `"code"`) || !strings.Contains(data, `"data"`) {
|
||||
t.Fatalf("scanned JSON should be the success envelope without an API code, got %q", data)
|
||||
if _, hasCode := data["code"]; hasCode {
|
||||
t.Fatalf("scanned data should be business data only, got %#v", data)
|
||||
}
|
||||
|
||||
var got map[string]interface{}
|
||||
@@ -634,11 +615,9 @@ func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
||||
if provider.path != "list" {
|
||||
t.Fatalf("scan path = %q, want list", provider.path)
|
||||
}
|
||||
// Streaming now scans the exact rendered page bytes (not the structured
|
||||
// item) so a rule match formed only in the rendered output cannot slip past.
|
||||
scanned, ok := provider.data.(string)
|
||||
if !ok || !strings.Contains(scanned, `"id":"1"`) {
|
||||
t.Fatalf("scanned data = %#v, want rendered ndjson page text", provider.data)
|
||||
items, ok := provider.data.([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
|
||||
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
|
||||
@@ -701,8 +680,11 @@ func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
||||
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
|
||||
}
|
||||
out := stdout.String()
|
||||
if out != "" {
|
||||
t.Fatalf("blocked complete stream was written before safety block: %s", out)
|
||||
if !strings.Contains(out, "safe-page") {
|
||||
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
|
||||
}
|
||||
if strings.Contains(out, "blocked-page") {
|
||||
t.Fatalf("blocked page was written before safety block: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -813,81 +795,26 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_UnknownFormat_Rejected(t *testing.T) {
|
||||
// No stub is registered: the unknown --format must be rejected before any
|
||||
// API call is made.
|
||||
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
|
||||
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/svc/v1/items",
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
|
||||
})
|
||||
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
|
||||
|
||||
// An unknown --format is a typed validation error, not a silent JSON fallback.
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error for unknown --format")
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
requireValidationParam(t, err, "--format")
|
||||
if !strings.Contains(err.Error(), "unknown output format") {
|
||||
t.Errorf("error = %v, want unknown-format message", err)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(err.Error()), "pretty") {
|
||||
t.Errorf("error = %v, raw service format choices must exclude pretty", err)
|
||||
}
|
||||
if stdout.String() != "" {
|
||||
t.Errorf("unknown --format must not write stdout, got:\n%s", stdout.String())
|
||||
}
|
||||
// The old degrade-to-JSON warning must be gone, not merely accompanied by an error.
|
||||
if strings.Contains(stderr.String(), "falling back to json") {
|
||||
t.Errorf("unknown --format must not emit the legacy fallback warning, got stderr:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_UnknownFormatPrecedesJqConflict(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{},
|
||||
})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "tabel", "--jq", "."})
|
||||
|
||||
err := cmd.Execute()
|
||||
requireValidationParam(t, err, "--format")
|
||||
if !strings.Contains(err.Error(), "unknown output format") {
|
||||
t.Fatalf("error = %v, want unknown-format message", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceMethod_PrettyRejectedOnEmit(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
|
||||
})
|
||||
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
|
||||
method := meta.FromMap(map[string]interface{}{
|
||||
"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{},
|
||||
})
|
||||
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
|
||||
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
|
||||
|
||||
err := cmd.Execute()
|
||||
requireValidationParam(t, err, "--format")
|
||||
if !strings.Contains(err.Error(), "pretty") {
|
||||
t.Fatalf("error = %v, want pretty-not-supported message", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("rejected --format pretty wrote stdout:\n%s", stdout.String())
|
||||
if !strings.Contains(stderr.String(), "warning: unknown format") {
|
||||
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1101,18 +1028,6 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err
|
||||
}
|
||||
}
|
||||
|
||||
func requireValidationParam(t *testing.T, err error, param string) {
|
||||
t.Helper()
|
||||
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != param {
|
||||
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
|
||||
}
|
||||
}
|
||||
|
||||
// ── file upload ──
|
||||
|
||||
func imImageMethod() meta.Method {
|
||||
|
||||
@@ -9,30 +9,17 @@ import (
|
||||
)
|
||||
|
||||
// Provider scans parsed response data for content-safety issues.
|
||||
// Implementations must be safe for concurrent use. Scan may be a best-effort
|
||||
// scan with bounded string length or nesting depth.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type Provider interface {
|
||||
Name() string
|
||||
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
|
||||
}
|
||||
|
||||
// FullTextProvider is a Provider that guarantees a complete scan of Data with
|
||||
// NO per-string or depth truncation. Block mode requires this capability so a
|
||||
// match anywhere in the output cannot slip past a truncation boundary.
|
||||
type FullTextProvider interface {
|
||||
Provider
|
||||
ScanFullText(ctx context.Context, req ScanRequest) (*Alert, error)
|
||||
}
|
||||
|
||||
// ScanRequest carries the data to scan.
|
||||
type ScanRequest struct {
|
||||
Path string // normalized command path (e.g. "im.messages_search")
|
||||
Data any // parsed response data (generic JSON shape)
|
||||
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
|
||||
// FullText marks Data as one complete rendered-output string. It remains a
|
||||
// compatibility hint for Provider.Scan; block mode calls
|
||||
// FullTextProvider.ScanFullText to enforce complete scanning.
|
||||
FullText bool
|
||||
}
|
||||
|
||||
// Alert holds the result of a content-safety scan that detected issues.
|
||||
|
||||
@@ -29,14 +29,6 @@ func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
|
||||
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
|
||||
}
|
||||
|
||||
type fullTextStubProvider struct {
|
||||
stubProvider
|
||||
}
|
||||
|
||||
func (s *fullTextStubProvider) ScanFullText(ctx context.Context, req ScanRequest) (*Alert, error) {
|
||||
return s.Scan(ctx, req)
|
||||
}
|
||||
|
||||
func TestProviderInterface(t *testing.T) {
|
||||
var p Provider = &stubProvider{}
|
||||
if p.Name() != "stub" {
|
||||
@@ -51,17 +43,6 @@ func TestProviderInterface(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFullTextProviderInterface(t *testing.T) {
|
||||
var p FullTextProvider = &fullTextStubProvider{}
|
||||
alert, err := p.ScanFullText(context.Background(), ScanRequest{Path: "test", Data: "full", ErrOut: io.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFullText() error = %v", err)
|
||||
}
|
||||
if alert.Provider != "stub" {
|
||||
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryLastWriteWins(t *testing.T) {
|
||||
mu.Lock()
|
||||
old := provider
|
||||
|
||||
@@ -711,19 +711,3 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
|
||||
t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginateToOutputRejectsUnsupportedInternalFormat(t *testing.T) {
|
||||
for _, format := range []output.Format{output.FormatPretty, output.Format(99)} {
|
||||
err := PaginateToOutput(context.Background(), PaginateOutputOptions{
|
||||
Request: RawApiRequest{},
|
||||
Format: format,
|
||||
Out: io.Discard,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture",
|
||||
})
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("format %q error = %T, want *errs.InternalError", format, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// PaginateOutputOptions bundles the inputs for PaginateToOutput. Grouping the
|
||||
// writers, callbacks, and pagination knobs into one struct keeps the call sites
|
||||
// readable and avoids positional-argument mistakes across the many parameters.
|
||||
type PaginateOutputOptions struct {
|
||||
Client *APIClient
|
||||
Request RawApiRequest
|
||||
Format output.Format
|
||||
JqExpr string
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
CommandPath string
|
||||
Pagination PaginationOptions
|
||||
CheckErr func(interface{}, core.Identity) error
|
||||
MarkErr func(error) error
|
||||
}
|
||||
|
||||
// PaginateToOutput fetches all requested pages and emits them in the selected format.
|
||||
func PaginateToOutput(ctx context.Context, opts PaginateOutputOptions) error {
|
||||
ac := opts.Client
|
||||
request := opts.Request
|
||||
format := opts.Format
|
||||
jqExpr := opts.JqExpr
|
||||
out := opts.Out
|
||||
errOut := opts.ErrOut
|
||||
commandPath := opts.CommandPath
|
||||
pagOpts := opts.Pagination
|
||||
checkErr := opts.CheckErr
|
||||
markErr := opts.MarkErr
|
||||
if !format.Valid() || format == output.FormatPretty {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unsupported pagination output format %q", format)
|
||||
}
|
||||
if markErr == nil {
|
||||
markErr = func(err error) error { return err }
|
||||
}
|
||||
if pagOpts.Identity == "" {
|
||||
pagOpts.Identity = request.As
|
||||
}
|
||||
emitValue := func(data interface{}, valueFormat output.Format) error {
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
})
|
||||
return emitter.Value(data, output.StreamOptions{Format: valueFormat})
|
||||
}
|
||||
// When jq is set, always aggregate all pages then filter.
|
||||
if jqExpr != "" {
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return markErr(err)
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
if emitErr := emitValue(result, output.FormatJSON); emitErr != nil {
|
||||
return markErr(emitErr)
|
||||
}
|
||||
return markErr(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
JqExpr: jqExpr,
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format})
|
||||
}, pagOpts)
|
||||
if err != nil && errs.IsContentSafety(err) {
|
||||
return markErr(err)
|
||||
}
|
||||
if finishErr := emitter.FinishStream(); finishErr != nil {
|
||||
return markErr(finishErr)
|
||||
}
|
||||
if err != nil {
|
||||
return markErr(err)
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
return markErr(apiErr)
|
||||
}
|
||||
if !hasItems {
|
||||
return emitter.Value(output.SuccessEnvelopeData(result), output.StreamOptions{Format: format})
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
result, err := ac.PaginateAll(ctx, request, pagOpts)
|
||||
if err != nil {
|
||||
return markErr(err)
|
||||
}
|
||||
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
|
||||
if emitErr := emitValue(result, output.FormatJSON); emitErr != nil {
|
||||
return markErr(emitErr)
|
||||
}
|
||||
return markErr(apiErr)
|
||||
}
|
||||
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
|
||||
CommandPath: commandPath,
|
||||
Identity: string(pagOpts.Identity),
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
Identity: string(identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
return emitter.Success(result, output.EmitOptions{Format: opts.Format})
|
||||
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
package output
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,19 +15,17 @@ import (
|
||||
|
||||
// ScanResult holds the output of ScanForSafety.
|
||||
type ScanResult struct {
|
||||
Alert *extcs.Alert
|
||||
Blocked bool
|
||||
BlockErr error
|
||||
scanFailed bool
|
||||
Alert *extcs.Alert
|
||||
Blocked bool
|
||||
BlockErr error
|
||||
}
|
||||
|
||||
// ScanForSafety scans structured response data.
|
||||
// ScanForSafety runs content-safety scanning on the given data.
|
||||
// cmdPath is the raw cobra CommandPath().
|
||||
// When MODE=off, no provider registered, or the command is not allowlisted,
|
||||
// returns a zero ScanResult.
|
||||
func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
|
||||
return scanForSafetyMode(cmdPath, data, errOut, false, modeFromEnv(errOut), defaultContentSafetyContext)
|
||||
}
|
||||
|
||||
func scanForSafetyMode(cmdPath string, data any, errOut io.Writer, fullText bool, m mode, newScanContext scanContextFactory) ScanResult {
|
||||
alert, csErr := runContentSafety(cmdPath, data, errOut, fullText, m, newScanContext)
|
||||
alert, csErr := runContentSafety(cmdPath, data, errOut)
|
||||
if errors.Is(csErr, errBlocked) {
|
||||
return ScanResult{
|
||||
Alert: alert,
|
||||
@@ -36,18 +33,10 @@ func scanForSafetyMode(cmdPath string, data any, errOut io.Writer, fullText bool
|
||||
BlockErr: wrapBlockError(alert),
|
||||
}
|
||||
}
|
||||
if errors.Is(csErr, errScanIncomplete) {
|
||||
return ScanResult{
|
||||
Blocked: true,
|
||||
BlockErr: wrapScanIncompleteError(csErr),
|
||||
}
|
||||
}
|
||||
if errors.Is(csErr, errScanFailed) {
|
||||
return ScanResult{scanFailed: true}
|
||||
}
|
||||
return ScanResult{Alert: alert}
|
||||
}
|
||||
|
||||
// wrapBlockError creates a typed error for content-safety block.
|
||||
func wrapBlockError(alert *extcs.Alert) error {
|
||||
var matchedRules []string
|
||||
if alert != nil {
|
||||
@@ -59,16 +48,8 @@ func wrapBlockError(alert *extcs.Alert) error {
|
||||
WithCause(errBlocked)
|
||||
}
|
||||
|
||||
func wrapScanIncompleteError(cause error) error {
|
||||
message := "content-safety scan did not complete; blocked (block mode)"
|
||||
if errors.Is(cause, context.DeadlineExceeded) {
|
||||
message = "content-safety scan did not complete in time; blocked (block mode)"
|
||||
}
|
||||
return errs.NewContentSafetyError(errs.SubtypeContentSafety, "%s", message).
|
||||
WithCause(cause)
|
||||
}
|
||||
|
||||
// WriteAlertWarning writes a content-safety warning.
|
||||
// WriteAlertWarning writes a human-readable content-safety warning to w.
|
||||
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
|
||||
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
|
||||
if alert == nil {
|
||||
return nil
|
||||
|
||||
@@ -6,7 +6,6 @@ package output
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -25,15 +24,11 @@ const (
|
||||
modeBlock
|
||||
)
|
||||
|
||||
// scanTimeout also bounds untruncated rendered-text scans.
|
||||
// scanTimeout caps the content-safety scan so it cannot dominate CLI latency.
|
||||
// 100 ms is generous for a regex walk of a typical API response (KB-scale JSON);
|
||||
// larger responses hit maxDepth/maxStringBytes well before this fires.
|
||||
const scanTimeout = 100 * time.Millisecond
|
||||
|
||||
type scanContextFactory func() (context.Context, context.CancelFunc)
|
||||
|
||||
func defaultContentSafetyContext() (context.Context, context.CancelFunc) {
|
||||
return context.WithTimeout(context.Background(), scanTimeout)
|
||||
}
|
||||
|
||||
// modeFromEnv reads LARKSUITE_CLI_CONTENT_SAFETY_MODE.
|
||||
func modeFromEnv(errOut io.Writer) mode {
|
||||
raw := strings.TrimSpace(os.Getenv(envvars.CliContentSafetyMode))
|
||||
@@ -71,13 +66,11 @@ func normalizeCommandPath(cobraPath string) string {
|
||||
return strings.Join(segs, ".")
|
||||
}
|
||||
|
||||
var (
|
||||
errBlocked = errors.New("content safety blocked")
|
||||
errScanFailed = errors.New("content safety scan failed")
|
||||
errScanIncomplete = errors.New("content safety scan incomplete")
|
||||
)
|
||||
var errBlocked = fmt.Errorf("content safety blocked")
|
||||
|
||||
func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText bool, m mode, newScanContext scanContextFactory) (*extcs.Alert, error) {
|
||||
// runContentSafety orchestrates the scan: mode check -> provider -> scan with timeout + panic recovery.
|
||||
func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Alert, error) {
|
||||
m := modeFromEnv(errOut)
|
||||
if m == modeOff {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -92,28 +85,17 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText boo
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
scan := p.Scan
|
||||
if m == modeBlock {
|
||||
fullTextProvider, ok := p.(extcs.FullTextProvider)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: provider %q does not support complete scans",
|
||||
errScanIncomplete, p.Name())
|
||||
}
|
||||
scan = fullTextProvider.ScanFullText
|
||||
}
|
||||
|
||||
type result struct {
|
||||
alert *extcs.Alert
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
if newScanContext == nil {
|
||||
newScanContext = defaultContentSafetyContext
|
||||
}
|
||||
ctx, cancel := newScanContext()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
|
||||
defer cancel()
|
||||
|
||||
// A timed-out provider may outlive this call, so it cannot share errOut.
|
||||
// Give the goroutine its own writer so it cannot race on errOut after timeout.
|
||||
// On success, we copy any provider notices to the real errOut.
|
||||
// On timeout, the buffer is owned by the goroutine until it finishes; no shared access.
|
||||
scanErrBuf := &bytes.Buffer{}
|
||||
go func() {
|
||||
defer func() {
|
||||
@@ -121,12 +103,7 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText boo
|
||||
ch <- result{nil, fmt.Errorf("content safety panic: %v", r)}
|
||||
}
|
||||
}()
|
||||
a, e := scan(ctx, extcs.ScanRequest{
|
||||
Path: cmdPath,
|
||||
Data: data,
|
||||
ErrOut: scanErrBuf,
|
||||
FullText: fullText,
|
||||
})
|
||||
a, e := p.Scan(ctx, extcs.ScanRequest{Path: cmdPath, Data: data, ErrOut: scanErrBuf})
|
||||
ch <- result{a, e}
|
||||
}()
|
||||
|
||||
@@ -136,22 +113,13 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText boo
|
||||
if scanErrBuf.Len() > 0 {
|
||||
_, _ = io.Copy(errOut, scanErrBuf)
|
||||
}
|
||||
if ctx.Err() != nil && m == modeBlock {
|
||||
return nil, fmt.Errorf("%w: %w", errScanIncomplete, ctx.Err())
|
||||
}
|
||||
case <-ctx.Done():
|
||||
if m == modeBlock {
|
||||
return nil, fmt.Errorf("%w: %w", errScanIncomplete, ctx.Err())
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %w", errScanFailed, ctx.Err())
|
||||
return nil, nil // timeout, fail-open; scanErrBuf stays with the goroutine
|
||||
}
|
||||
|
||||
if res.err != nil {
|
||||
fmt.Fprintf(errOut, "warning: content safety scan error: %v\n", res.err)
|
||||
if m == modeBlock {
|
||||
return nil, fmt.Errorf("%w: %w", errScanIncomplete, res.err)
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %w", errScanFailed, res.err)
|
||||
return nil, nil // fail-open
|
||||
}
|
||||
if res.alert == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -23,70 +22,11 @@ type mockProvider struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type resultFirstCanceledContext struct {
|
||||
selectDone chan struct{}
|
||||
providerDone chan struct{}
|
||||
selectWaiting chan struct{}
|
||||
doneCallCounter atomic.Int32
|
||||
}
|
||||
|
||||
func newResultFirstCanceledContext() *resultFirstCanceledContext {
|
||||
providerDone := make(chan struct{})
|
||||
close(providerDone)
|
||||
return &resultFirstCanceledContext{
|
||||
selectDone: make(chan struct{}),
|
||||
providerDone: providerDone,
|
||||
selectWaiting: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *resultFirstCanceledContext) Deadline() (time.Time, bool) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func (c *resultFirstCanceledContext) Done() <-chan struct{} {
|
||||
if c.doneCallCounter.Add(1) == 1 {
|
||||
close(c.selectWaiting)
|
||||
return c.selectDone
|
||||
}
|
||||
return c.providerDone
|
||||
}
|
||||
|
||||
func (c *resultFirstCanceledContext) Err() error {
|
||||
return context.DeadlineExceeded
|
||||
}
|
||||
|
||||
func (c *resultFirstCanceledContext) Value(any) any {
|
||||
return nil
|
||||
}
|
||||
|
||||
type abortedCleanProvider struct {
|
||||
selectWaiting <-chan struct{}
|
||||
}
|
||||
|
||||
func (p *abortedCleanProvider) Name() string {
|
||||
return "aborted-clean"
|
||||
}
|
||||
|
||||
func (p *abortedCleanProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
<-p.selectWaiting
|
||||
<-ctx.Done()
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p *abortedCleanProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.Scan(ctx, req)
|
||||
}
|
||||
|
||||
func (m *mockProvider) Name() string { return m.name }
|
||||
func (m *mockProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return m.alert, m.err
|
||||
}
|
||||
|
||||
func (m *mockProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return m.Scan(ctx, req)
|
||||
}
|
||||
|
||||
func TestScanForSafety_ModeOff(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
var buf bytes.Buffer
|
||||
@@ -162,131 +102,36 @@ func TestScanForSafety_NoProvider(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForSafety_ScanError_ModeBehavior(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
mode string
|
||||
wantBlocked bool
|
||||
wantWarning bool
|
||||
}{
|
||||
{name: "block fails closed", mode: "block", wantBlocked: true, wantWarning: true},
|
||||
{name: "warn fails open", mode: "warn", wantWarning: true},
|
||||
{name: "off skips scan", mode: "off"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
|
||||
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
|
||||
extcs.Register(mp)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
func TestScanForSafety_ScanError_FailOpen(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
|
||||
extcs.Register(mp)
|
||||
defer extcs.Register(nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if result.Blocked != tt.wantBlocked {
|
||||
t.Fatalf("Blocked = %v, want %v", result.Blocked, tt.wantBlocked)
|
||||
}
|
||||
if tt.wantBlocked {
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(result.BlockErr, &safetyErr) {
|
||||
t.Fatalf("BlockErr = %T, want *errs.ContentSafetyError", result.BlockErr)
|
||||
}
|
||||
if !strings.Contains(safetyErr.Message, "scan did not complete") {
|
||||
t.Fatalf("BlockErr message = %q, want scan-incomplete message", safetyErr.Message)
|
||||
}
|
||||
if !errors.Is(result.BlockErr, errScanIncomplete) {
|
||||
t.Fatal("BlockErr should preserve errScanIncomplete cause")
|
||||
}
|
||||
}
|
||||
if got := strings.Contains(buf.String(), "scan error"); got != tt.wantWarning {
|
||||
t.Fatalf("scan warning present = %v, want %v; stderr=%q", got, tt.wantWarning, buf.String())
|
||||
}
|
||||
})
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if result.Blocked {
|
||||
t.Error("scan error should fail-open, not block")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "scan error") {
|
||||
t.Errorf("expected warning on stderr, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForSafety_SlowProvider_TimeoutModeBehavior(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
mode string
|
||||
wantBlocked bool
|
||||
}{
|
||||
{name: "block fails closed", mode: "block", wantBlocked: true},
|
||||
{name: "warn fails open", mode: "warn"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
|
||||
extcs.Register(&slowProvider{})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
func TestScanForSafety_SlowProvider_Timeout_FailOpen(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if result.Blocked != tt.wantBlocked {
|
||||
t.Fatalf("Blocked = %v, want %v", result.Blocked, tt.wantBlocked)
|
||||
}
|
||||
if result.Alert != nil {
|
||||
t.Error("slow provider should return nil alert on timeout")
|
||||
}
|
||||
if tt.wantBlocked {
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(result.BlockErr, &safetyErr) {
|
||||
t.Fatalf("BlockErr = %T, want *errs.ContentSafetyError", result.BlockErr)
|
||||
}
|
||||
if !strings.Contains(safetyErr.Message, "did not complete in time") {
|
||||
t.Fatalf("BlockErr message = %q, want timeout message", safetyErr.Message)
|
||||
}
|
||||
}
|
||||
})
|
||||
slow := &slowProvider{}
|
||||
extcs.Register(slow)
|
||||
defer extcs.Register(nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if result.Blocked {
|
||||
t.Error("slow provider should fail-open on timeout, not block")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterAbortedCleanLookingScanModeBehavior(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
wantBlocked bool
|
||||
}{
|
||||
{name: "block fails closed", mode: "block", wantBlocked: true},
|
||||
{name: "warn fails open", mode: "warn"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
|
||||
scanCtx := newResultFirstCanceledContext()
|
||||
extcs.Register(&abortedCleanProvider{selectWaiting: scanCtx.selectWaiting})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := NewEmitter(EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: &bytes.Buffer{},
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
})
|
||||
emitter.scanCtx = func() (context.Context, context.CancelFunc) {
|
||||
return scanCtx, func() {}
|
||||
}
|
||||
err := emitter.Success(map[string]any{"id": "1"}, EmitOptions{Format: FormatJSON})
|
||||
|
||||
if tt.wantBlocked {
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
|
||||
}
|
||||
if !strings.Contains(safetyErr.Message, "scan did not complete") {
|
||||
t.Fatalf("Emitter.Success() error = %v, want scan-incomplete message", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v, want nil", err)
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
t.Fatal("Emitter.Success() stdout is empty, want emitted output")
|
||||
}
|
||||
})
|
||||
if result.Alert != nil {
|
||||
t.Error("slow provider should return nil alert on timeout")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,10 +148,6 @@ func (s *slowProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Al
|
||||
}
|
||||
}
|
||||
|
||||
func (s *slowProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return s.Scan(ctx, req)
|
||||
}
|
||||
|
||||
func TestWriteAlertWarning(t *testing.T) {
|
||||
alert := &extcs.Alert{Provider: "regex", MatchedRules: []string{"r1", "r2"}}
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -6,11 +6,11 @@ package output
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"maps"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
)
|
||||
|
||||
// NoticeProvider supplies the notice attached to a structured envelope.
|
||||
@@ -25,30 +25,31 @@ type PrettyRenderer func(w io.Writer, colorEnabled bool) error
|
||||
// EmitterConfig contains command-scoped dependencies. A command constructs one
|
||||
// Emitter and reuses it for its success result or streamed pages.
|
||||
type EmitterConfig struct {
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
CommandPath string
|
||||
Identity string
|
||||
ColorEnabled bool
|
||||
NoticeProvider NoticeProvider
|
||||
MaxBufferedStreamBytes int
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
CommandPath string
|
||||
Identity string
|
||||
ColorEnabled bool
|
||||
NoticeProvider NoticeProvider
|
||||
}
|
||||
|
||||
// EmitOptions describes one result's wire representation.
|
||||
//
|
||||
// The format contract is explicit: FormatJSON (the zero value) uses an
|
||||
// The format contract is explicit: JSON (including the empty default) uses an
|
||||
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
|
||||
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
|
||||
// envelope encoding and jq's complex-value encoding. Format is a canonical
|
||||
// typed value — boundaries reject unknown formats via ParseFormatStrict, so the
|
||||
// Emitter never sees one and never falls back.
|
||||
// envelope encoding and jq's complex-value encoding.
|
||||
//
|
||||
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
|
||||
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
|
||||
type EmitOptions struct {
|
||||
Raw bool
|
||||
Meta *Meta
|
||||
Format Format
|
||||
JQ string
|
||||
DryRun bool
|
||||
Pretty PrettyRenderer
|
||||
Raw bool
|
||||
Meta *Meta
|
||||
Format string
|
||||
JQ string
|
||||
DryRun bool
|
||||
Pretty PrettyRenderer
|
||||
JQSafetyWarning bool
|
||||
}
|
||||
|
||||
// StreamOptions describes one streamed page's wire representation. Streaming
|
||||
@@ -58,7 +59,7 @@ type EmitOptions struct {
|
||||
// the aggregated result, which the caller's pagination layer owns before it
|
||||
// streams pages.
|
||||
type StreamOptions struct {
|
||||
Format Format
|
||||
Format string
|
||||
Pretty PrettyRenderer
|
||||
}
|
||||
|
||||
@@ -71,33 +72,17 @@ type Emitter struct {
|
||||
identity string
|
||||
colorEnabled bool
|
||||
noticeProvider NoticeProvider
|
||||
scanCtx scanContextFactory
|
||||
|
||||
streamFormat Format
|
||||
streamFormatSet bool
|
||||
streamPrettySet bool
|
||||
streamHasPretty bool
|
||||
streamFormat string
|
||||
streamFormatter *PaginatedFormatter
|
||||
streamMode mode
|
||||
streamModeSet bool
|
||||
streamBuffer bytes.Buffer
|
||||
maxStreamBytes int
|
||||
streamFinished bool
|
||||
streamFinishErr error
|
||||
}
|
||||
|
||||
const defaultMaxBufferedStreamBytes = 64 << 20
|
||||
|
||||
// NewEmitter constructs a command-scoped output emitter.
|
||||
func NewEmitter(config EmitterConfig) *Emitter {
|
||||
errOut := config.ErrOut
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
maxStreamBytes := config.MaxBufferedStreamBytes
|
||||
if maxStreamBytes <= 0 {
|
||||
maxStreamBytes = defaultMaxBufferedStreamBytes
|
||||
}
|
||||
return &Emitter{
|
||||
out: config.Out,
|
||||
errOut: errOut,
|
||||
@@ -105,8 +90,6 @@ func NewEmitter(config EmitterConfig) *Emitter {
|
||||
identity: config.Identity,
|
||||
colorEnabled: config.ColorEnabled,
|
||||
noticeProvider: config.NoticeProvider,
|
||||
scanCtx: defaultContentSafetyContext,
|
||||
maxStreamBytes: maxStreamBytes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,10 +97,6 @@ func NewEmitter(config EmitterConfig) *Emitter {
|
||||
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
|
||||
// ndjson render the business value directly.
|
||||
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
if !opts.Format.Valid() {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(opts.Format))
|
||||
}
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -127,49 +106,26 @@ func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
|
||||
}
|
||||
|
||||
switch opts.Format {
|
||||
case FormatJSON:
|
||||
case "", "json":
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
case FormatPretty:
|
||||
case "pretty":
|
||||
return e.emitPretty(data, opts)
|
||||
default:
|
||||
return e.emitFormatted(data, opts.Format)
|
||||
}
|
||||
}
|
||||
|
||||
// Value scans and emits one naked business value. It is intended for
|
||||
// long-running streams and custom-format shortcuts whose public contract does
|
||||
// not use the standard success envelope.
|
||||
func (e *Emitter) Value(data interface{}, opts StreamOptions) error {
|
||||
if !opts.Format.Valid() {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(opts.Format))
|
||||
}
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Format == FormatPretty && opts.Pretty != nil {
|
||||
return e.emitPrettyRenderer(data, opts.Pretty)
|
||||
}
|
||||
return e.emitValue(data, opts.Format)
|
||||
}
|
||||
|
||||
// PartialFailure emits a multi-status result whose envelope honestly reports
|
||||
// ok:false. It is the typed counterpart to Success for batch operations where
|
||||
// some items failed but the per-item outcomes are the primary stdout output.
|
||||
// JSON and jq retain the failure envelope. Other formats emit the selected
|
||||
// naked representation while the caller supplies the non-zero exit signal.
|
||||
// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
|
||||
// caller owns the non-zero exit signal, keeping the Emitter free of exit
|
||||
// semantics.
|
||||
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
|
||||
if !opts.Format.Valid() {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(opts.Format))
|
||||
}
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.JQ != "" || opts.Format == FormatJSON {
|
||||
return e.emitEnvelope(data, false, opts)
|
||||
}
|
||||
return e.Value(data, StreamOptions{Format: opts.Format, Pretty: opts.Pretty})
|
||||
return e.emitEnvelope(data, false, opts)
|
||||
}
|
||||
|
||||
// StreamPage scans and emits one page while retaining table/csv columns from
|
||||
@@ -180,80 +136,54 @@ func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
|
||||
// jq from the type makes "jq requires aggregated output" a compile-time fact
|
||||
// instead of a runtime rejection.
|
||||
func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
|
||||
if !opts.Format.Valid() {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(opts.Format))
|
||||
}
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.streamFinished {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"stream output is already finished")
|
||||
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if !e.streamFormatSet {
|
||||
if scanResult.Alert != nil {
|
||||
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
|
||||
if opts.Format == "pretty" {
|
||||
if opts.Pretty == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"pretty output requires a renderer")
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return opts.Pretty(w, e.colorEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
format, known := ParseFormat(opts.Format)
|
||||
if !known && e.streamFormatter == nil && e.errOut != nil {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
|
||||
}
|
||||
if e.streamFormatter == nil {
|
||||
e.streamFormat = opts.Format
|
||||
e.streamFormatSet = true
|
||||
e.streamFormatter = NewPaginatedFormatter(nil, format)
|
||||
} else if opts.Format != e.streamFormat {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"stream output format changed from %q to %q", e.streamFormat, opts.Format)
|
||||
}
|
||||
|
||||
if opts.Format == FormatPretty {
|
||||
hasPretty := opts.Pretty != nil
|
||||
if !e.streamPrettySet {
|
||||
e.streamHasPretty = hasPretty
|
||||
e.streamPrettySet = true
|
||||
} else if hasPretty != e.streamHasPretty {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"stream pretty renderer availability changed between pages")
|
||||
}
|
||||
if opts.Pretty != nil {
|
||||
var buf bytes.Buffer
|
||||
if err := opts.Pretty(&buf, e.colorEnabled); err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
return e.emitStreamBuffer(data, &buf)
|
||||
}
|
||||
// Commands without a curated pretty renderer use the generic table
|
||||
// representation. This keeps --format pretty truthful without requiring
|
||||
// every shortcut to duplicate a renderer.
|
||||
opts.Format = FormatTable
|
||||
}
|
||||
|
||||
if e.streamFormatter == nil {
|
||||
e.streamFormatter = NewPaginatedFormatter(nil, opts.Format)
|
||||
}
|
||||
|
||||
// Render this page, then scan the exact bytes before writing: a rule match
|
||||
// can form in the rendered page (joined table cells, adjacent objects) even
|
||||
// when no single value matches.
|
||||
var buf bytes.Buffer
|
||||
e.streamFormatter.W = &buf
|
||||
if err := e.streamFormatter.WritePage(data); err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
return e.emitStreamBuffer(data, &buf)
|
||||
}
|
||||
|
||||
// FinishStream commits output buffered by StreamPage in block mode. Warn mode
|
||||
// remains incremental: each page is scanned and written by StreamPage. Callers
|
||||
// must invoke FinishStream after the final page, including when pagination ends
|
||||
// with an API error and partial block-mode output should remain visible.
|
||||
func (e *Emitter) FinishStream() error {
|
||||
if e.streamFinished {
|
||||
return e.streamFinishErr
|
||||
}
|
||||
e.streamFinished = true
|
||||
if !e.streamModeSet || e.streamMode != modeBlock || e.streamBuffer.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
e.streamFinishErr = e.emitScannedBufferMode(&e.streamBuffer, e.streamMode)
|
||||
return e.streamFinishErr
|
||||
return e.emit(func(w io.Writer) error {
|
||||
e.streamFormatter.W = w
|
||||
return e.streamFormatter.WritePage(data)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
|
||||
m := modeFromEnv(e.errOut)
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
|
||||
env := Envelope{
|
||||
OK: ok,
|
||||
Identity: e.identity,
|
||||
@@ -262,14 +192,15 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro
|
||||
Meta: opts.Meta,
|
||||
Notice: e.notice(),
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
|
||||
if opts.JQ != "" {
|
||||
sourceScan := e.scanForSafetyMode(data, false, m)
|
||||
if sourceScan.Blocked {
|
||||
return sourceScan.BlockErr
|
||||
}
|
||||
if sourceScan.Alert != nil {
|
||||
env.ContentSafetyAlert = sourceScan.Alert
|
||||
if scanResult.Alert != nil && opts.JQSafetyWarning {
|
||||
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
// Buffer the jq output manually so jq's own typed error (a validation
|
||||
// error for a bad expression, an api error for a runtime failure) is
|
||||
@@ -285,121 +216,25 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro
|
||||
if jqErr != nil {
|
||||
return jqErr
|
||||
}
|
||||
var renderedScan ScanResult
|
||||
if !sourceScan.scanFailed {
|
||||
renderedScan = e.scanRenderedBufferMode(&buf, m)
|
||||
}
|
||||
if renderedScan.Blocked {
|
||||
return renderedScan.BlockErr
|
||||
}
|
||||
alert := mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert)
|
||||
if alert != nil {
|
||||
if err := WriteAlertWarning(e.errOut, alert); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(e.out, &buf); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scan both representations. The structured scan detects content changed by
|
||||
// JSON escaping, while the rendered scan detects matches formed across
|
||||
// serialized fields.
|
||||
sourceScan := e.scanForSafetyMode(data, false, m)
|
||||
if sourceScan.Blocked {
|
||||
return sourceScan.BlockErr
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := renderEnvelope(&buf, env, opts.Raw); err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
var renderedScan ScanResult
|
||||
if !sourceScan.scanFailed {
|
||||
renderedScan = e.scanRenderedBufferMode(&buf, m)
|
||||
}
|
||||
if renderedScan.Blocked {
|
||||
return renderedScan.BlockErr
|
||||
}
|
||||
if alert := mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert); alert != nil {
|
||||
env.ContentSafetyAlert = alert
|
||||
buf.Reset()
|
||||
if err := renderEnvelope(&buf, env, opts.Raw); err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
return e.emit(func(w io.Writer) error {
|
||||
if opts.Raw {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(env)
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(e.out, &buf); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
return WriteJSON(w, env)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
|
||||
if opts.Pretty != nil {
|
||||
return e.emitPrettyRenderer(data, opts.Pretty)
|
||||
}
|
||||
|
||||
return e.emitFormatted(data, FormatPretty)
|
||||
}
|
||||
|
||||
func (e *Emitter) emitPrettyRenderer(data interface{}, renderer PrettyRenderer) error {
|
||||
// Buffer pretty output so the safety scan sees the exact text that will be
|
||||
// written to stdout, including anything captured by the opaque renderer.
|
||||
var buf bytes.Buffer
|
||||
if err := renderer(&buf, e.colorEnabled); err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
|
||||
}
|
||||
|
||||
// emitFormatted renders naked business data for ndjson, table, csv, and the
|
||||
// generic pretty representation. Success routes FormatJSON to the envelope and
|
||||
// curated pretty output to its renderer.
|
||||
func (e *Emitter) emitFormatted(data interface{}, format Format) error {
|
||||
var buf bytes.Buffer
|
||||
if err := WriteFormatted(&buf, data, format); err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
|
||||
}
|
||||
|
||||
func (e *Emitter) emitValue(data interface{}, format Format) error {
|
||||
var buf bytes.Buffer
|
||||
var err error
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
err = WriteJSON(&buf, data)
|
||||
case FormatNDJSON:
|
||||
err = WriteNDJSON(&buf, data)
|
||||
case FormatTable:
|
||||
err = WriteTable(&buf, data)
|
||||
case FormatCSV:
|
||||
err = WriteCSV(&buf, data)
|
||||
case FormatPretty:
|
||||
err = WriteFormatted(&buf, data, format)
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(format))
|
||||
}
|
||||
if err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
|
||||
}
|
||||
|
||||
func (e *Emitter) emitScannedBufferMode(buf *bytes.Buffer, m mode) error {
|
||||
scanResult := e.scanRenderedBufferMode(buf, m)
|
||||
return e.emitBufferAfterScan(buf, scanResult)
|
||||
}
|
||||
|
||||
func (e *Emitter) emitSourceAndRenderedBufferMode(data interface{}, buf *bytes.Buffer, m mode) error {
|
||||
scanResult := e.scanSourceAndRenderedBufferMode(data, buf, m)
|
||||
return e.emitBufferAfterScan(buf, scanResult)
|
||||
}
|
||||
|
||||
func (e *Emitter) emitBufferAfterScan(buf *bytes.Buffer, scanResult ScanResult) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
@@ -408,97 +243,79 @@ func (e *Emitter) emitBufferAfterScan(buf *bytes.Buffer, scanResult ScanResult)
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
if _, err := io.Copy(e.out, buf); err != nil {
|
||||
if opts.Pretty != nil {
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return opts.Pretty(w, e.colorEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
// RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
|
||||
// renderer is supplied. Keep that second scan visible in the leaf contract
|
||||
// until production callers are migrated and the legacy behavior is removed.
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
}
|
||||
|
||||
format, known := ParseFormat(rawFormat)
|
||||
if !known && e.errOut != nil {
|
||||
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
|
||||
}
|
||||
if format == FormatJSON {
|
||||
return e.printLegacyDataJSON(data)
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteFormatted(w, data, format)
|
||||
})
|
||||
}
|
||||
|
||||
type emitterDataMap map[string]interface{}
|
||||
|
||||
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
|
||||
// data from this Emitter instead of PrintJson's global PendingNotice hook.
|
||||
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
|
||||
// Normalise structs / named maps to plain generic types first, exactly as
|
||||
// FormatValue does, so a struct or named-map payload still matches the map
|
||||
// case below and keeps its injected _notice on the unknown-format fallback.
|
||||
data = toGeneric(data)
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if _, isEnvelope := m["ok"]; isEnvelope {
|
||||
if notice := e.notice(); notice != nil {
|
||||
m = maps.Clone(m)
|
||||
m["_notice"] = notice
|
||||
}
|
||||
}
|
||||
// The named map retains identical JSON bytes while preventing PrintJson
|
||||
// from consulting its legacy global notice hook a second time.
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, emitterDataMap(m))
|
||||
})
|
||||
}
|
||||
return e.emit(func(w io.Writer) error {
|
||||
return WriteJSON(w, data)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Emitter) emit(render func(io.Writer) error) error {
|
||||
var buf bytes.Buffer
|
||||
if err := render(&buf); err != nil {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
if _, err := io.Copy(e.out, &buf); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Emitter) emitStreamBuffer(data interface{}, buf *bytes.Buffer) error {
|
||||
if !e.streamModeSet {
|
||||
e.streamMode = modeFromEnv(e.errOut)
|
||||
e.streamModeSet = true
|
||||
}
|
||||
switch e.streamMode {
|
||||
case modeWarn:
|
||||
return e.emitSourceAndRenderedBufferMode(data, buf, e.streamMode)
|
||||
case modeBlock:
|
||||
sourceScan := e.scanForSafetyMode(data, false, e.streamMode)
|
||||
if sourceScan.Blocked {
|
||||
return sourceScan.BlockErr
|
||||
}
|
||||
if buf.Len() > e.maxStreamBytes-e.streamBuffer.Len() {
|
||||
return errs.NewContentSafetyError(errs.SubtypeContentSafety,
|
||||
"content-safety scan input exceeds the %d-byte stream limit; blocked",
|
||||
e.maxStreamBytes).
|
||||
WithHint("reduce --page-limit or request fewer records")
|
||||
}
|
||||
_, _ = e.streamBuffer.Write(buf.Bytes())
|
||||
return nil
|
||||
}
|
||||
if _, err := io.Copy(e.out, buf); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Emitter) scanSourceAndRenderedBufferMode(data interface{}, buf *bytes.Buffer, m mode) ScanResult {
|
||||
sourceScan := e.scanForSafetyMode(data, false, m)
|
||||
if sourceScan.Blocked || sourceScan.scanFailed {
|
||||
return sourceScan
|
||||
}
|
||||
renderedScan := e.scanRenderedBufferMode(buf, m)
|
||||
if renderedScan.Blocked {
|
||||
return renderedScan
|
||||
}
|
||||
renderedScan.Alert = mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert)
|
||||
return renderedScan
|
||||
}
|
||||
|
||||
func (e *Emitter) scanRenderedBufferMode(buf *bytes.Buffer, m mode) ScanResult {
|
||||
return e.scanForSafetyMode(buf.String(), true, m)
|
||||
}
|
||||
|
||||
func renderEnvelope(w io.Writer, env Envelope, raw bool) error {
|
||||
if raw {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(env)
|
||||
}
|
||||
return WriteJSON(w, env)
|
||||
}
|
||||
|
||||
func mergeSafetyAlerts(first, second *extcs.Alert) *extcs.Alert {
|
||||
if first == nil {
|
||||
return second
|
||||
}
|
||||
if second == nil {
|
||||
return first
|
||||
}
|
||||
rules := make(map[string]struct{}, len(first.MatchedRules)+len(second.MatchedRules))
|
||||
for _, rule := range first.MatchedRules {
|
||||
rules[rule] = struct{}{}
|
||||
}
|
||||
for _, rule := range second.MatchedRules {
|
||||
rules[rule] = struct{}{}
|
||||
}
|
||||
mergedRules := make([]string, 0, len(rules))
|
||||
for rule := range rules {
|
||||
mergedRules = append(mergedRules, rule)
|
||||
}
|
||||
sort.Strings(mergedRules)
|
||||
provider := first.Provider
|
||||
if provider == "" {
|
||||
provider = second.Provider
|
||||
}
|
||||
return &extcs.Alert{Provider: provider, MatchedRules: mergedRules}
|
||||
}
|
||||
|
||||
func (e *Emitter) scanForSafetyMode(data interface{}, fullText bool, m mode) ScanResult {
|
||||
return scanForSafetyMode(e.commandPath, data, e.errOut, fullText, m, e.scanCtx)
|
||||
}
|
||||
|
||||
func wrapOutputError(op string, err error) error {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,10 +45,6 @@ func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs
|
||||
return p.alert, p.err
|
||||
}
|
||||
|
||||
func (p *emitterSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.Scan(ctx, req)
|
||||
}
|
||||
|
||||
const (
|
||||
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
|
||||
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
|
||||
@@ -64,7 +60,6 @@ type runtimeContextOracleCase struct {
|
||||
format string
|
||||
useFormat bool
|
||||
pretty bool
|
||||
keepError bool
|
||||
notice map[string]interface{}
|
||||
safetyMode string
|
||||
safetyAlert *extcs.Alert
|
||||
@@ -193,6 +188,15 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
useFormat: true,
|
||||
pretty: true,
|
||||
},
|
||||
{
|
||||
name: "pretty_without_renderer",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"name": "Alice"}
|
||||
},
|
||||
ok: true,
|
||||
format: "pretty",
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "ndjson",
|
||||
data: func() interface{} {
|
||||
@@ -232,7 +236,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "jq_safety_alert_writes_stderr_warning",
|
||||
name: "jq_safety_alert_without_stderr_warning",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
@@ -245,7 +249,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scanner_error_warn_mode_fails_open",
|
||||
name: "scanner_error_fails_open",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
@@ -253,16 +257,6 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
safetyMode: "warn",
|
||||
safetyErr: errors.New("scanner unavailable"),
|
||||
},
|
||||
// Block mode intentionally fails closed when scanning errors.
|
||||
{
|
||||
name: "scanner_error_block_mode_fails_closed",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: false,
|
||||
safetyMode: "block",
|
||||
safetyErr: errors.New("scanner unavailable"),
|
||||
},
|
||||
{
|
||||
name: "scanner_block",
|
||||
data: func() interface{} {
|
||||
@@ -275,6 +269,16 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown_format_data_envelope_notice",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
},
|
||||
ok: true,
|
||||
format: "yaml",
|
||||
useFormat: true,
|
||||
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
|
||||
},
|
||||
}
|
||||
|
||||
golden := loadRuntimeContextLegacyGolden(t)
|
||||
@@ -305,11 +309,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
format: tc.format,
|
||||
useFormat: tc.useFormat,
|
||||
pretty: tc.pretty,
|
||||
keepError: tc.keepError,
|
||||
}
|
||||
// tc.format is the string a shortcut's --format flag would carry; the
|
||||
// boundary parses it to a canonical Format before the Emitter sees it.
|
||||
format, _ := output.ParseFormat(tc.format)
|
||||
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
@@ -317,10 +317,10 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
}, tc.ok, output.EmitOptions{
|
||||
Raw: tc.raw,
|
||||
Meta: tc.meta,
|
||||
Format: format,
|
||||
Format: tc.format,
|
||||
JQ: tc.jq,
|
||||
Pretty: emitterPrettyRenderer(tc.pretty),
|
||||
}, tc.keepError)
|
||||
})
|
||||
|
||||
assertEmitterGolden(t, want, current)
|
||||
|
||||
@@ -388,15 +388,10 @@ type runtimeOracleOptions struct {
|
||||
format string
|
||||
useFormat bool
|
||||
pretty bool
|
||||
keepError bool
|
||||
}
|
||||
|
||||
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
|
||||
t.Helper()
|
||||
if opts.keepError {
|
||||
return runRuntimeContextShortcutOracle(t, data, opts)
|
||||
}
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
parent := &cobra.Command{Use: "lark-cli"}
|
||||
@@ -436,42 +431,6 @@ func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleO
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||
}
|
||||
|
||||
func runRuntimeContextShortcutOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
factory, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true}
|
||||
fixture := &cobra.Command{Use: "fixture"}
|
||||
root.AddCommand(fixture)
|
||||
|
||||
shortcut := common.Shortcut{
|
||||
Service: "fixture",
|
||||
Command: "+emit",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
pretty := func(w io.Writer) {
|
||||
fmt.Fprintln(w, "pretty:fixture")
|
||||
}
|
||||
if !opts.pretty {
|
||||
pretty = nil
|
||||
}
|
||||
if opts.raw {
|
||||
runtime.OutFormatRaw(data, opts.meta, pretty)
|
||||
} else {
|
||||
runtime.OutFormat(data, opts.meta, pretty)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
shortcut.Mount(fixture, factory)
|
||||
root.SetArgs([]string{"fixture", "+emit", "--as", "bot", "--format", opts.format})
|
||||
|
||||
err := root.Execute()
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||
}
|
||||
|
||||
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
@@ -487,13 +446,7 @@ func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, o
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||
}
|
||||
|
||||
func runEmitterWithRuntimeContextContract(
|
||||
data interface{},
|
||||
config output.EmitterConfig,
|
||||
ok bool,
|
||||
opts output.EmitOptions,
|
||||
keepError bool,
|
||||
) emitterCapture {
|
||||
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
|
||||
capture := runEmitterSuccess(data, config, ok, opts)
|
||||
if capture.err != nil {
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
@@ -504,9 +457,6 @@ func runEmitterWithRuntimeContextContract(
|
||||
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
|
||||
return capture
|
||||
}
|
||||
if keepError {
|
||||
return capture
|
||||
}
|
||||
capture.err = nil
|
||||
}
|
||||
if !ok {
|
||||
@@ -596,10 +546,11 @@ func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
|
||||
Identity: "bot",
|
||||
NoticeProvider: func() map[string]interface{} { return notice },
|
||||
}, true, output.EmitOptions{
|
||||
Format: output.FormatJSON,
|
||||
Raw: false,
|
||||
JQ: tc.jq,
|
||||
DryRun: tc.dryRun,
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: tc.jq,
|
||||
DryRun: tc.dryRun,
|
||||
JQSafetyWarning: true,
|
||||
})
|
||||
assertEmitterGolden(t, want, current)
|
||||
|
||||
@@ -654,14 +605,23 @@ func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
|
||||
{name: "table", format: output.FormatTable},
|
||||
{name: "csv", format: output.FormatCSV},
|
||||
{
|
||||
name: "table warn",
|
||||
format: output.FormatTable,
|
||||
name: "warn",
|
||||
format: output.FormatNDJSON,
|
||||
safetyMode: "warn",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "block",
|
||||
format: output.FormatTable,
|
||||
safetyMode: "block",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pages := []interface{}{
|
||||
@@ -680,7 +640,7 @@ func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
legacy := runPaginationOracle(pages, tc.format)
|
||||
current := runEmitterStreamPages(pages, tc.format)
|
||||
current := runEmitterStreamPages(pages, tc.format.String())
|
||||
|
||||
assertEmitterBytes(t, legacy, current)
|
||||
assertEquivalentError(t, legacy.err, current.err)
|
||||
@@ -707,7 +667,7 @@ func runPaginationOracle(pages []interface{}, format output.Format) emitterCaptu
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
|
||||
}
|
||||
|
||||
func runEmitterStreamPages(pages []interface{}, format output.Format) emitterCapture {
|
||||
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
@@ -722,9 +682,6 @@ func runEmitterStreamPages(pages []interface{}, format output.Format) emitterCap
|
||||
break
|
||||
}
|
||||
}
|
||||
if emitErr == nil {
|
||||
emitErr = emitter.FinishStream()
|
||||
}
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
|
||||
}
|
||||
|
||||
@@ -749,7 +706,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
|
||||
return map[string]interface{}{"source": "captured"}
|
||||
},
|
||||
})
|
||||
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: output.FormatJSON}); err != nil {
|
||||
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
|
||||
@@ -757,7 +714,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: output.FormatPretty,
|
||||
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
|
||||
Pretty: func(w io.Writer, colorEnabled bool) error {
|
||||
colorSeen = colorEnabled
|
||||
_, err := fmt.Fprintln(w, "pretty")
|
||||
@@ -769,6 +726,14 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
|
||||
if !colorSeen {
|
||||
t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
|
||||
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
type failingEmitterWriter struct {
|
||||
@@ -786,7 +751,7 @@ func TestEmitterPropagatesOutputError(t *testing.T) {
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Raw: true, Format: output.FormatJSON,
|
||||
Raw: true, Format: "json",
|
||||
JQ: ".data",
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
|
||||
@@ -41,9 +41,10 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
|
||||
Identity: opts.Identity,
|
||||
NoticeProvider: GetNotice,
|
||||
}).Success(data, EmitOptions{
|
||||
Format: FormatJSON,
|
||||
Raw: false,
|
||||
JQ: opts.JqExpr,
|
||||
DryRun: opts.DryRun,
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: opts.JqExpr,
|
||||
DryRun: opts.DryRun,
|
||||
JQSafetyWarning: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// Known array field names for pagination.
|
||||
@@ -116,22 +114,8 @@ func FormatValue(w io.Writer, data interface{}, format Format) {
|
||||
|
||||
// WriteFormatted formats a single response and returns marshal or write errors.
|
||||
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
|
||||
if !format.Valid() {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(format))
|
||||
}
|
||||
data = toGeneric(data)
|
||||
switch format {
|
||||
case FormatJSON:
|
||||
return WriteJSON(w, data)
|
||||
case FormatPretty:
|
||||
switch data.(type) {
|
||||
case map[string]interface{}, []interface{}:
|
||||
return WriteTable(w, data)
|
||||
default:
|
||||
_, err := fmt.Fprintln(w, cellStr(data))
|
||||
return err
|
||||
}
|
||||
case FormatNDJSON:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
@@ -153,9 +137,9 @@ func WriteFormatted(w io.Writer, data interface{}, format Format) error {
|
||||
}
|
||||
return WriteCSV(w, data)
|
||||
|
||||
default: // FormatJSON
|
||||
return WriteJSON(w, data)
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(format))
|
||||
}
|
||||
|
||||
// PaginatedFormatter holds state across paginated calls to ensure
|
||||
@@ -182,10 +166,6 @@ func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
||||
|
||||
// WritePage formats one page of items and returns marshal or write errors.
|
||||
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
|
||||
if !pf.Format.Valid() {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(pf.Format))
|
||||
}
|
||||
switch pf.Format {
|
||||
case FormatJSON, FormatNDJSON:
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
@@ -214,8 +194,7 @@ func (pf *PaginatedFormatter) WritePage(data interface{}) error {
|
||||
return writeCSVRows(w, rows, cols, isFirst)
|
||||
})
|
||||
}
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unknown output format %d", int(pf.Format))
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatStructuredPage handles column-locking logic shared by table and csv.
|
||||
|
||||
@@ -6,11 +6,8 @@ package output
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestFormatValue_JSON(t *testing.T) {
|
||||
@@ -101,18 +98,6 @@ func TestFormatValue_CSV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFormatted_InvalidFormatReturnsInternalErrorWithoutOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := WriteFormatted(&buf, map[string]interface{}{"id": "1"}, Format(99))
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("WriteFormatted() problem = %#v, %v; want internal/unknown", problem, ok)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("WriteFormatted() wrote %d bytes, want 0", buf.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatJSON)
|
||||
@@ -185,22 +170,6 @@ func TestPaginatedFormatter_CSV(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatterWritePage_InvalidFormatReturnsInternalErrorWithoutOutput(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, Format(99))
|
||||
err := pf.WritePage([]interface{}{map[string]interface{}{"id": "1"}})
|
||||
var internalErr *errs.InternalError
|
||||
if !errors.As(err, &internalErr) {
|
||||
t.Fatalf("WritePage() error = %T, want *errs.InternalError", err)
|
||||
}
|
||||
if internalErr.Category != errs.CategoryInternal || internalErr.Subtype != errs.SubtypeUnknown {
|
||||
t.Fatalf("WritePage() problem = %s/%s, want internal/unknown", internalErr.Category, internalErr.Subtype)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("WritePage() wrote %d bytes, want 0", buf.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_ColumnConsistency(t *testing.T) {
|
||||
// Page 1 has {a, b}, page 2 has {a, b, c} — c should be ignored in CSV
|
||||
var buf bytes.Buffer
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
// Format represents an output format type.
|
||||
type Format int
|
||||
@@ -18,22 +13,11 @@ const (
|
||||
FormatNDJSON
|
||||
FormatTable
|
||||
FormatCSV
|
||||
FormatPretty
|
||||
)
|
||||
|
||||
// Valid reports whether f is one of the defined output formats.
|
||||
func (f Format) Valid() bool {
|
||||
return f >= FormatJSON && f <= FormatPretty
|
||||
}
|
||||
|
||||
// ParseFormat parses a format string into a Format value.
|
||||
// The second return value is false if the format string was not recognized,
|
||||
// in which case FormatJSON is returned as default.
|
||||
//
|
||||
// Prefer ParseFormatStrict at flag boundaries so an unknown --format fails
|
||||
// loudly instead of degrading to JSON. ParseFormat's lenient fallback is kept
|
||||
// for internal callers that only need a best-effort classification (e.g.
|
||||
// ValidateJqFlags, which folds any non-JSON — known or not — into one branch).
|
||||
func ParseFormat(s string) (Format, bool) {
|
||||
switch strings.ToLower(s) {
|
||||
case "json", "":
|
||||
@@ -44,41 +28,21 @@ func ParseFormat(s string) (Format, bool) {
|
||||
return FormatTable, true
|
||||
case "csv":
|
||||
return FormatCSV, true
|
||||
case "pretty":
|
||||
return FormatPretty, true
|
||||
default:
|
||||
return FormatJSON, false
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFormatStrict parses a --format value into a typed Format, returning a
|
||||
// typed ValidationError for any unrecognized value instead of silently falling
|
||||
// back to JSON. Flag boundaries use this so an unknown format is a typed
|
||||
// failure the caller cannot accidentally serve as JSON, and so the Emitter
|
||||
// downstream only ever receives a canonical Format.
|
||||
func ParseFormatStrict(s string) (Format, error) {
|
||||
if f, ok := ParseFormat(s); ok {
|
||||
return f, nil
|
||||
}
|
||||
return FormatJSON, errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"unknown output format %q (want json, ndjson, table, csv, or pretty)", s).
|
||||
WithParam("--format")
|
||||
}
|
||||
|
||||
// String returns the string representation of a Format.
|
||||
func (f Format) String() string {
|
||||
switch f {
|
||||
case FormatJSON:
|
||||
return "json"
|
||||
case FormatNDJSON:
|
||||
return "ndjson"
|
||||
case FormatTable:
|
||||
return "table"
|
||||
case FormatCSV:
|
||||
return "csv"
|
||||
case FormatPretty:
|
||||
return "pretty"
|
||||
default:
|
||||
return fmt.Sprintf("unknown(%d)", int(f))
|
||||
return "json"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
import "testing"
|
||||
|
||||
func TestParseFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -27,9 +23,6 @@ func TestParseFormat(t *testing.T) {
|
||||
{"csv", FormatCSV, true},
|
||||
{"CSV", FormatCSV, true},
|
||||
{"Csv", FormatCSV, true},
|
||||
{"pretty", FormatPretty, true},
|
||||
{"PRETTY", FormatPretty, true},
|
||||
{"Pretty", FormatPretty, true},
|
||||
{"", FormatJSON, true},
|
||||
// Legacy/unknown values fall back to JSON with ok=false
|
||||
{"data", FormatJSON, false},
|
||||
@@ -62,8 +55,7 @@ func TestFormatString(t *testing.T) {
|
||||
{FormatNDJSON, "ndjson"},
|
||||
{FormatTable, "table"},
|
||||
{FormatCSV, "csv"},
|
||||
{FormatPretty, "pretty"},
|
||||
{Format(99), "unknown(99)"},
|
||||
{Format(99), "json"}, // unknown falls back
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -75,59 +67,3 @@ func TestFormatString(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValid(t *testing.T) {
|
||||
for _, format := range []Format{FormatJSON, FormatNDJSON, FormatTable, FormatCSV, FormatPretty} {
|
||||
if !format.Valid() {
|
||||
t.Errorf("Format(%d).Valid() = false, want true", format)
|
||||
}
|
||||
}
|
||||
if Format(99).Valid() {
|
||||
t.Error("Format(99).Valid() = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFormatStrict(t *testing.T) {
|
||||
valid := []struct {
|
||||
input string
|
||||
want Format
|
||||
}{
|
||||
{"", FormatJSON},
|
||||
{"json", FormatJSON},
|
||||
{"JSON", FormatJSON},
|
||||
{"ndjson", FormatNDJSON},
|
||||
{"table", FormatTable},
|
||||
{"csv", FormatCSV},
|
||||
{"pretty", FormatPretty},
|
||||
{"Pretty", FormatPretty},
|
||||
}
|
||||
for _, tt := range valid {
|
||||
t.Run("valid/"+tt.input, func(t *testing.T) {
|
||||
got, err := ParseFormatStrict(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFormatStrict(%q) error = %v, want nil", tt.input, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("ParseFormatStrict(%q) = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Unknown values are a typed validation error on --format, never a silent
|
||||
// fallback to JSON.
|
||||
for _, input := range []string{"yaml", "xml", "data", "raw", "tabel"} {
|
||||
t.Run("unknown/"+input, func(t *testing.T) {
|
||||
got, err := ParseFormatStrict(input)
|
||||
if err == nil {
|
||||
t.Fatalf("ParseFormatStrict(%q) error = nil, want validation error", input)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation {
|
||||
t.Fatalf("ParseFormatStrict(%q) problem = %#v, %v; want validation category", input, problem, ok)
|
||||
}
|
||||
if got != FormatJSON {
|
||||
t.Errorf("ParseFormatStrict(%q) format = %v, want FormatJSON sentinel", input, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,14 +70,7 @@ func ValidateJqFlags(jqExpr, outputFlag, format string) error {
|
||||
if outputFlag != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --output are mutually exclusive")
|
||||
}
|
||||
// Classify via ParseFormat so the JSON check is case-insensitive and shares
|
||||
// the single canonical format definition. Only a recognized JSON format is
|
||||
// compatible with --jq; every other value conflicts and is rejected: known
|
||||
// non-JSON framework formats ("csv", "pretty", ...) and values ParseFormat
|
||||
// does not recognize as JSON (a shortcut's own "markdown"/"data" enum, or an
|
||||
// unknown format that ParseFormatStrict rejects downstream). The !ok guard
|
||||
// keeps those unrecognized values out of the JSON-compatible branch.
|
||||
if f, ok := ParseFormat(format); !ok || f != FormatJSON {
|
||||
if format != "" && format != "json" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --format %s are mutually exclusive", format)
|
||||
}
|
||||
return ValidateJqExpression(jqExpr)
|
||||
|
||||
@@ -160,13 +160,8 @@ func TestValidateJqFlags(t *testing.T) {
|
||||
{name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""},
|
||||
{name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""},
|
||||
{name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""},
|
||||
// Format classification is case-insensitive via ParseFormat: an
|
||||
// upper/mixed-case JSON must not be mistaken for a conflicting format.
|
||||
{name: "jq with uppercase JSON format", jqExpr: ".data", outputFlag: "", format: "JSON", wantErr: ""},
|
||||
{name: "jq with mixed-case Json format", jqExpr: ".data", outputFlag: "", format: "Json", wantErr: ""},
|
||||
{name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"},
|
||||
{name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"},
|
||||
{name: "jq and pretty conflict", jqExpr: ".data", outputFlag: "", format: "pretty", wantErr: "--jq and --format pretty are mutually exclusive"},
|
||||
{name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"},
|
||||
{name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"},
|
||||
}
|
||||
|
||||
@@ -81,6 +81,21 @@ func injectNotice(data interface{}) {
|
||||
m["_notice"] = notice
|
||||
}
|
||||
|
||||
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
|
||||
func PrintNdjson(w io.Writer, data interface{}) {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
|
||||
func WriteNDJSON(w io.Writer, data interface{}) error {
|
||||
emit := func(item interface{}) error {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"stderr": ""
|
||||
},
|
||||
"format_raw_json_preserves_html": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"<p>a&b</p>\"\n }\n}\n",
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq_invalid_expression": {
|
||||
@@ -22,9 +22,9 @@
|
||||
"exit_code": 2
|
||||
}
|
||||
},
|
||||
"jq_safety_alert_writes_stderr_warning": {
|
||||
"jq_safety_alert_without_stderr_warning": {
|
||||
"stdout": "1\n",
|
||||
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
|
||||
"stderr": ""
|
||||
},
|
||||
"jq_scalar": {
|
||||
"stdout": "Alice\n",
|
||||
@@ -62,12 +62,16 @@
|
||||
"stdout": "pretty:fixture\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"pretty_without_renderer": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"raw_jq_complex": {
|
||||
"stdout": "{\n \"html\": \"<p>a&b</p>\"\n}\n",
|
||||
"stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"raw_json_preserves_html": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"<p>a&b</p>\"\n }\n}\n",
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"scanner_block": {
|
||||
@@ -87,27 +91,17 @@
|
||||
"exit_code": 6
|
||||
}
|
||||
},
|
||||
"scanner_error_block_mode_fails_closed": {
|
||||
"stdout": "",
|
||||
"stderr": "warning: content safety scan error: scanner unavailable\n",
|
||||
"error": {
|
||||
"go_type": "*errs.ContentSafetyError",
|
||||
"json": {
|
||||
"type": "policy",
|
||||
"subtype": "content_safety",
|
||||
"message": "content-safety scan did not complete; blocked (block mode)"
|
||||
},
|
||||
"message": "content-safety scan did not complete; blocked (block mode)",
|
||||
"exit_code": 6
|
||||
}
|
||||
},
|
||||
"scanner_error_warn_mode_fails_open": {
|
||||
"scanner_error_fails_open": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
|
||||
"stderr": "warning: content safety scan error: scanner unavailable\n"
|
||||
},
|
||||
"table_with_safety_warning": {
|
||||
"stdout": "id name \n── ─────\n1 Alice\n",
|
||||
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
|
||||
},
|
||||
"unknown_format_data_envelope_notice": {
|
||||
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
|
||||
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,15 +24,6 @@ type regexProvider struct {
|
||||
func (p *regexProvider) Name() string { return "regex" }
|
||||
|
||||
func (p *regexProvider) Scan(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.scan(ctx, req, false)
|
||||
}
|
||||
|
||||
func (p *regexProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
req.FullText = true
|
||||
return p.scan(ctx, req, true)
|
||||
}
|
||||
|
||||
func (p *regexProvider) scan(ctx context.Context, req extcs.ScanRequest, fullText bool) (*extcs.Alert, error) {
|
||||
cfg, err := p.loadOrCreate(req.ErrOut)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -46,11 +37,9 @@ func (p *regexProvider) scan(ctx context.Context, req extcs.ScanRequest, fullTex
|
||||
}
|
||||
|
||||
data := normalize(req.Data)
|
||||
s := &scanner{rules: cfg.Rules, fullText: fullText}
|
||||
s := &scanner{rules: cfg.Rules}
|
||||
hits := make(map[string]struct{})
|
||||
if err := s.walk(ctx, data, hits, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.walk(ctx, data, hits, 0)
|
||||
|
||||
if len(hits) == 0 {
|
||||
return nil, nil
|
||||
|
||||
@@ -4,22 +4,15 @@
|
||||
package contentsafety
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
var _ extcs.FullTextProvider = (*regexProvider)(nil)
|
||||
|
||||
func writeTestConfig(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -77,28 +70,6 @@ func TestProvider_ScanCleanData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ScanCanceledContextReturnsError(t *testing.T) {
|
||||
dir := writeTestConfig(t, `{
|
||||
"allowlist": ["all"],
|
||||
"rules": [{"id": "r1", "pattern": "(?i)inject"}]
|
||||
}`)
|
||||
p := ®exProvider{configDir: dir}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
alert, err := p.Scan(ctx, extcs.ScanRequest{
|
||||
Path: "im.messages_search",
|
||||
Data: map[string]any{"text": "Hello, clean data"},
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if alert != nil {
|
||||
t.Fatalf("Scan() alert = %v, want nil", alert)
|
||||
}
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("Scan() error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ScanNotInAllowlist(t *testing.T) {
|
||||
dir := writeTestConfig(t, `{
|
||||
"allowlist": ["im"],
|
||||
@@ -170,169 +141,6 @@ func TestProvider_ScanNestedData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_FullTextBypassesPerStringCap(t *testing.T) {
|
||||
dir := writeTestConfig(t, `{
|
||||
"allowlist": ["all"],
|
||||
"rules": [{"id": "tail", "pattern": "TAIL_MARKER"}]
|
||||
}`)
|
||||
p := ®exProvider{configDir: dir}
|
||||
text := strings.Repeat("x", maxStringBytes+1) + "TAIL_MARKER"
|
||||
|
||||
alert, err := p.Scan(context.Background(), extcs.ScanRequest{
|
||||
Path: "test",
|
||||
Data: text,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Scan() structured-data error = %v", err)
|
||||
}
|
||||
if alert != nil {
|
||||
t.Fatalf("structured-data scan should retain the per-string cap, got %v", alert)
|
||||
}
|
||||
|
||||
alert, err = p.ScanFullText(context.Background(), extcs.ScanRequest{
|
||||
Path: "test",
|
||||
Data: text,
|
||||
ErrOut: io.Discard,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ScanFullText() error = %v", err)
|
||||
}
|
||||
if alert == nil || len(alert.MatchedRules) != 1 || alert.MatchedRules[0] != "tail" {
|
||||
t.Fatalf("full-text scan alert = %v, want tail match", alert)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterStructuredBlockFullTextWritesZeroBytesAndWarnEmits(t *testing.T) {
|
||||
dir := writeTestConfig(t, `{
|
||||
"allowlist": ["all"],
|
||||
"rules": [
|
||||
{"id": "prefix", "pattern": "PREFIX_MARKER"},
|
||||
{"id": "tail", "pattern": "TAIL_MARKER"}
|
||||
]
|
||||
}`)
|
||||
p := ®exProvider{configDir: dir}
|
||||
extcs.Register(p)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
data := map[string]any{
|
||||
"text": "PREFIX_MARKER" + strings.Repeat("x", maxStringBytes+1) + "TAIL_MARKER",
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
blockStdout := &bytes.Buffer{}
|
||||
blockEmitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: blockStdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := blockEmitter.Success(data, output.EmitOptions{Format: output.FormatJSON})
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("block Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
|
||||
}
|
||||
foundTail := false
|
||||
for _, ruleID := range safetyErr.Rules {
|
||||
if ruleID == "tail" {
|
||||
foundTail = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundTail {
|
||||
t.Fatalf("block matched rules = %v, want tail match beyond per-string cap", safetyErr.Rules)
|
||||
}
|
||||
if blockStdout.Len() != 0 {
|
||||
t.Fatalf("block stdout bytes = %d, want 0", blockStdout.Len())
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
warnStdout := &bytes.Buffer{}
|
||||
warnStderr := &bytes.Buffer{}
|
||||
warnEmitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: warnStdout,
|
||||
ErrOut: warnStderr,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
if err := warnEmitter.Success(data, output.EmitOptions{Format: output.FormatJSON}); err != nil {
|
||||
t.Fatalf("warn Emitter.Success() error = %v", err)
|
||||
}
|
||||
if warnStdout.Len() == 0 {
|
||||
t.Fatal("warn stdout bytes = 0, want emitted structured payload")
|
||||
}
|
||||
if !strings.Contains(warnStdout.String(), `"_content_safety_alert"`) ||
|
||||
!strings.Contains(warnStdout.String(), `"prefix"`) {
|
||||
t.Fatalf("warn stdout = %q, want embedded prefix content-safety warning", warnStdout.String())
|
||||
}
|
||||
if warnStderr.Len() != 0 {
|
||||
t.Fatalf("warn stderr = %q, want empty for JSON envelope warning", warnStderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterStructuredBlockDepthIncompleteWritesZeroBytes(t *testing.T) {
|
||||
dir := writeTestConfig(t, `{
|
||||
"allowlist": ["all"],
|
||||
"rules": [{"id": "deep", "pattern": "DEEP_MARKER"}]
|
||||
}`)
|
||||
p := ®exProvider{configDir: dir}
|
||||
extcs.Register(p)
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
var data any = "DEEP_MARKER"
|
||||
for i := 0; i < maxDepth+5; i++ {
|
||||
data = map[string]any{"nested": data}
|
||||
}
|
||||
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(data, output.EmitOptions{Format: output.FormatJSON})
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
|
||||
}
|
||||
if !strings.Contains(safetyErr.Message, "scan did not complete") {
|
||||
t.Fatalf("Emitter.Success() error = %v, want scan-incomplete message", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("block stdout bytes = %d, want 0", stdout.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_ScanDetectsInjectionInMapKey(t *testing.T) {
|
||||
// A rule match hiding in a map key (which JSON/NDJSON/table/CSV all emit)
|
||||
// must be detected, not just matches in values.
|
||||
dir := writeTestConfig(t, `{
|
||||
"allowlist": ["all"],
|
||||
"rules": [{"id": "override", "pattern": "(?i)ignore previous instructions"}]
|
||||
}`)
|
||||
p := ®exProvider{configDir: dir}
|
||||
data := map[string]any{"ignore previous instructions": "ok"}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
scan func() (*extcs.Alert, error)
|
||||
}{
|
||||
{"Scan", func() (*extcs.Alert, error) {
|
||||
return p.Scan(context.Background(), extcs.ScanRequest{Path: "test", Data: data, ErrOut: io.Discard})
|
||||
}},
|
||||
{"ScanFullText", func() (*extcs.Alert, error) {
|
||||
return p.ScanFullText(context.Background(), extcs.ScanRequest{Path: "test", Data: data, ErrOut: io.Discard})
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
alert, err := tc.scan()
|
||||
if err != nil {
|
||||
t.Fatalf("%s() error = %v", tc.name, err)
|
||||
}
|
||||
if alert == nil || len(alert.MatchedRules) != 1 || alert.MatchedRules[0] != "override" {
|
||||
t.Fatalf("%s() alert = %v, want override match on the map key", tc.name, alert)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_EmptyRulesNoAlert(t *testing.T) {
|
||||
dir := writeTestConfig(t, `{"allowlist":["all"],"rules":[]}`)
|
||||
p := ®exProvider{configDir: dir}
|
||||
|
||||
@@ -5,8 +5,6 @@ package contentsafety
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
@@ -15,52 +13,38 @@ const (
|
||||
maxDepth = 64
|
||||
)
|
||||
|
||||
var errScanIncomplete = errors.New("content safety scan incomplete")
|
||||
|
||||
type rule struct {
|
||||
ID string
|
||||
Pattern *regexp.Regexp
|
||||
}
|
||||
|
||||
type scanner struct {
|
||||
rules []rule
|
||||
fullText bool
|
||||
rules []rule
|
||||
}
|
||||
|
||||
func (s *scanner) walk(ctx context.Context, v any, hits map[string]struct{}, depth int) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
func (s *scanner) walk(ctx context.Context, v any, hits map[string]struct{}, depth int) {
|
||||
if depth > maxDepth {
|
||||
if s.fullText {
|
||||
return fmt.Errorf("%w: maximum depth %d exceeded", errScanIncomplete, maxDepth)
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
s.scanString(t, hits)
|
||||
case map[string]any:
|
||||
for k, child := range t {
|
||||
// Scan the key too: JSON/NDJSON/table/CSV all emit map keys, so a
|
||||
// rule match hiding in a key must not slip past block mode.
|
||||
s.scanString(k, hits)
|
||||
if err := s.walk(ctx, child, hits, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, child := range t {
|
||||
s.walk(ctx, child, hits, depth+1)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range t {
|
||||
if err := s.walk(ctx, child, hits, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
s.walk(ctx, child, hits, depth+1)
|
||||
}
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func (s *scanner) scanString(text string, hits map[string]struct{}) {
|
||||
if !s.fullText && len(text) > maxStringBytes {
|
||||
if len(text) > maxStringBytes {
|
||||
text = text[:maxStringBytes]
|
||||
}
|
||||
for _, r := range s.rules {
|
||||
|
||||
@@ -5,7 +5,6 @@ package contentsafety
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
@@ -46,23 +45,6 @@ func TestScanString_Truncate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanString_FullTextDoesNotTruncate(t *testing.T) {
|
||||
s := &scanner{
|
||||
rules: []rule{testRule("tail", `TAIL_MARKER`)},
|
||||
fullText: true,
|
||||
}
|
||||
big := make([]byte, maxStringBytes+100)
|
||||
for i := range big {
|
||||
big[i] = 'x'
|
||||
}
|
||||
copy(big[maxStringBytes+10:], "TAIL_MARKER")
|
||||
hits := make(map[string]struct{})
|
||||
s.scanString(string(big), hits)
|
||||
if _, ok := hits["tail"]; !ok {
|
||||
t.Error("full-text scan should match marker beyond maxStringBytes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanString_SkipsDuplicate(t *testing.T) {
|
||||
s := &scanner{rules: []rule{testRule("r1", `match`)}}
|
||||
hits := map[string]struct{}{"r1": {}}
|
||||
@@ -80,34 +62,16 @@ func TestWalk_NestedMap(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hits := make(map[string]struct{})
|
||||
if err := s.walk(context.Background(), data, hits, 0); err != nil {
|
||||
t.Fatalf("walk() error = %v", err)
|
||||
}
|
||||
s.walk(context.Background(), data, hits, 0)
|
||||
if _, ok := hits["found"]; !ok {
|
||||
t.Error("expected to find 'inject' in nested map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalk_ScansMapKeys(t *testing.T) {
|
||||
// JSON/NDJSON/table/CSV all emit map keys, so a rule match hiding in a key
|
||||
// must be scanned too — not only the value.
|
||||
s := &scanner{rules: []rule{testRule("found", `(?i)inject`)}}
|
||||
data := map[string]any{"please inject this": "harmless value"}
|
||||
hits := make(map[string]struct{})
|
||||
if err := s.walk(context.Background(), data, hits, 0); err != nil {
|
||||
t.Fatalf("walk() error = %v", err)
|
||||
}
|
||||
if _, ok := hits["found"]; !ok {
|
||||
t.Error("expected to match a rule hiding in a map key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalk_Array(t *testing.T) {
|
||||
s := &scanner{rules: []rule{testRule("found", `(?i)inject`)}}
|
||||
hits := make(map[string]struct{})
|
||||
if err := s.walk(context.Background(), []any{"normal", "try to inject"}, hits, 0); err != nil {
|
||||
t.Fatalf("walk() error = %v", err)
|
||||
}
|
||||
s.walk(context.Background(), []any{"normal", "try to inject"}, hits, 0)
|
||||
if _, ok := hits["found"]; !ok {
|
||||
t.Error("expected to find 'inject' in array")
|
||||
}
|
||||
@@ -120,42 +84,18 @@ func TestWalk_MaxDepth(t *testing.T) {
|
||||
data = map[string]any{"n": data}
|
||||
}
|
||||
hits := make(map[string]struct{})
|
||||
if err := s.walk(context.Background(), data, hits, 0); err != nil {
|
||||
t.Fatalf("walk() error = %v", err)
|
||||
}
|
||||
s.walk(context.Background(), data, hits, 0)
|
||||
if _, ok := hits["deep"]; ok {
|
||||
t.Error("should not reach string beyond maxDepth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalk_FullTextMaxDepthReturnsIncomplete(t *testing.T) {
|
||||
s := &scanner{
|
||||
rules: []rule{testRule("deep", `secret`)},
|
||||
fullText: true,
|
||||
}
|
||||
var data any = "secret"
|
||||
for i := 0; i < maxDepth+5; i++ {
|
||||
data = map[string]any{"n": data}
|
||||
}
|
||||
hits := make(map[string]struct{})
|
||||
err := s.walk(context.Background(), data, hits, 0)
|
||||
if !errors.Is(err, errScanIncomplete) {
|
||||
t.Fatalf("walk() error = %v, want errScanIncomplete", err)
|
||||
}
|
||||
if _, ok := hits["deep"]; ok {
|
||||
t.Error("full-text walk should report incomplete before matching data beyond maxDepth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWalk_ContextCancel(t *testing.T) {
|
||||
s := &scanner{rules: []rule{testRule("found", `target`)}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
hits := make(map[string]struct{})
|
||||
err := s.walk(ctx, map[string]any{"key": "target"}, hits, 0)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("walk() error = %v, want context.Canceled", err)
|
||||
}
|
||||
s.walk(ctx, map[string]any{"key": "target"}, hits, 0)
|
||||
if _, ok := hits["found"]; ok {
|
||||
t.Error("should not match after context cancel")
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
|
||||
return value, nil
|
||||
}
|
||||
if _, err := SafeInputPath(value); err != nil {
|
||||
return "", fmt.Errorf("%s: %w", flagName, err)
|
||||
return "", fmt.Errorf("%s: %v", flagName, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@ package base
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf
|
||||
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
|
||||
if runtime.JqExpr != "" {
|
||||
if !runtime.Changed("format") {
|
||||
runtime.OutJSON(data, nil)
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
|
||||
@@ -38,13 +39,32 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
|
||||
rendered, err := renderer(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
|
||||
runtime.OutJSON(data, nil)
|
||||
runtime.Out(data, nil)
|
||||
return nil
|
||||
}
|
||||
return runtime.EmitRenderedValue(data, func(w io.Writer) error {
|
||||
_, writeErr := io.WriteString(w, rendered)
|
||||
return writeErr
|
||||
})
|
||||
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return baseContentSafetyBlockError(scanResult)
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
|
||||
}
|
||||
fmt.Fprint(runtime.IO().Out, rendered)
|
||||
return nil
|
||||
}
|
||||
|
||||
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
|
||||
message := "content safety violation detected"
|
||||
var rules []string
|
||||
if scanResult.Alert != nil {
|
||||
rules = scanResult.Alert.MatchedRules
|
||||
}
|
||||
if len(rules) > 0 {
|
||||
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
|
||||
}
|
||||
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
|
||||
WithRules(rules...).
|
||||
WithCause(scanResult.BlockErr)
|
||||
}
|
||||
|
||||
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {
|
||||
|
||||
@@ -31,10 +31,6 @@ func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanReque
|
||||
return p.alert, nil
|
||||
}
|
||||
|
||||
func (p *recordMarkdownCSTestProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.Scan(ctx, req)
|
||||
}
|
||||
|
||||
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
|
||||
parentCmd := &cobra.Command{Use: "lark-cli"}
|
||||
baseCmd := &cobra.Command{Use: "base"}
|
||||
@@ -45,7 +41,6 @@ func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeC
|
||||
return &common.RuntimeContext{
|
||||
Config: &core.CliConfig{Brand: core.BrandFeishu},
|
||||
Cmd: cmd,
|
||||
Format: "markdown",
|
||||
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ var CalendarSearchEvent = common.Shortcut{
|
||||
})
|
||||
|
||||
if hasMore && runtime.Format != "json" && runtime.Format != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n(more available, page_token: %s)\n", pageToken)
|
||||
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -1352,7 +1352,7 @@ func TestAgenda_Success(t *testing.T) {
|
||||
"+agenda",
|
||||
"--start", "2025-03-21",
|
||||
"--end", "2025-03-21",
|
||||
"--format", "pretty",
|
||||
"--format", "prettry",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
|
||||
|
||||
@@ -698,78 +698,41 @@ func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer
|
||||
}
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) emitOutput(data interface{}, meta *output.Meta, raw bool, prettyFn func(w io.Writer), formatName string) {
|
||||
format, ok := output.ParseFormat(formatName)
|
||||
if !ok {
|
||||
ctx.handleEmitterError(errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"output helper received unsupported format %q", formatName))
|
||||
return
|
||||
}
|
||||
// Out prints a success JSON envelope to stdout.
|
||||
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
||||
Format: format,
|
||||
Raw: raw,
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
Pretty: wrapLegacyPrettyRenderer(prettyFn),
|
||||
}))
|
||||
}
|
||||
|
||||
// EmitValue writes a naked business value with the selected formatter. Custom
|
||||
// output contracts and long-running callbacks use this method when a success
|
||||
// envelope would change their wire representation.
|
||||
func (ctx *RuntimeContext) EmitValue(data interface{}, formatName string) error {
|
||||
format, err := output.ParseFormatStrict(formatName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.newEmitter().Value(data, output.StreamOptions{Format: format})
|
||||
}
|
||||
|
||||
// EmitRenderedValue writes a caller-rendered naked value after scanning both
|
||||
// the structured source and the exact rendered bytes.
|
||||
func (ctx *RuntimeContext) EmitRenderedValue(data interface{}, renderer func(io.Writer) error) error {
|
||||
return ctx.newEmitter().Value(data, output.StreamOptions{
|
||||
Format: output.FormatPretty,
|
||||
Pretty: func(w io.Writer, _ bool) error {
|
||||
return renderer(w)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Out prints a success result using the selected output format.
|
||||
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
ctx.emitOutput(data, meta, false, nil, ctx.Format)
|
||||
}
|
||||
|
||||
// OutRaw prints a success result using the selected output format. JSON
|
||||
// envelope output preserves XML/HTML content without escaping.
|
||||
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
|
||||
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
|
||||
// that should be preserved as-is in JSON output.
|
||||
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
|
||||
ctx.emitOutput(data, meta, true, nil, ctx.Format)
|
||||
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
||||
Format: "",
|
||||
Raw: true,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
}))
|
||||
}
|
||||
|
||||
// OutJSON prints a success JSON envelope regardless of a shortcut's custom
|
||||
// format flag. It is reserved for branches whose output contract is JSON.
|
||||
func (ctx *RuntimeContext) OutJSON(data interface{}, meta *output.Meta) {
|
||||
ctx.emitOutput(data, meta, false, nil, output.FormatJSON.String())
|
||||
}
|
||||
|
||||
// OutPartialFailure writes a multi-status result in the selected format and
|
||||
// returns the partial-failure exit signal. JSON and jq retain an ok:false
|
||||
// envelope; table, csv, ndjson, and pretty emit the full result as naked data.
|
||||
// The process exits non-zero and stdout remains parseable in the requested
|
||||
// format.
|
||||
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
|
||||
// and returns the partial-failure exit signal. Use it for batch operations
|
||||
// where some items failed but the per-item outcomes are the primary output:
|
||||
// the full result (summary + per-item statuses) stays machine-readable on
|
||||
// stdout, the process exits non-zero, and nothing is written to stderr.
|
||||
//
|
||||
// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the
|
||||
// JSON's ok field honestly reports failure, and the exit signal is distinct
|
||||
// from ErrBare (the stdout-carries-the-answer silent-exit signal).
|
||||
// envelope's ok field honestly reports failure instead of a misleading
|
||||
// ok:true, and the exit signal is distinct from ErrBare (the
|
||||
// stdout-carries-the-answer silent-exit signal).
|
||||
func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error {
|
||||
format, err := output.ParseFormatStrict(ctx.Format)
|
||||
if err != nil {
|
||||
ctx.handleEmitterError(err)
|
||||
return err
|
||||
}
|
||||
ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{
|
||||
Format: format,
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
@@ -785,13 +748,25 @@ func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta
|
||||
// When JqExpr is set, envelope filtering takes precedence over format.
|
||||
// The Emitter handles content safety scanning for every format.
|
||||
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
||||
ctx.emitOutput(data, meta, false, prettyFn, ctx.Format)
|
||||
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
||||
Format: ctx.Format,
|
||||
Raw: false,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
Pretty: wrapLegacyPrettyRenderer(prettyFn),
|
||||
}))
|
||||
}
|
||||
|
||||
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
|
||||
// Use this when the data contains XML/HTML content that should be preserved as-is.
|
||||
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
|
||||
ctx.emitOutput(data, meta, true, prettyFn, ctx.Format)
|
||||
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
||||
Format: ctx.Format,
|
||||
Raw: true,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
Pretty: wrapLegacyPrettyRenderer(prettyFn),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Scope pre-check ──
|
||||
@@ -901,25 +876,18 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
|
||||
// runShortcut is the execution pipeline for a declarative shortcut.
|
||||
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
|
||||
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
|
||||
// --print-schema short-circuits everything below: it's pure local
|
||||
// introspection, no identity / scope / network needed. The flag is
|
||||
// only registered when the shortcut opts in via PrintFlagSchema.
|
||||
if s.PrintFlagSchema != nil {
|
||||
if want, _ := cmd.Flags().GetBool("print-schema"); want {
|
||||
formatName, _ := cmd.Flags().GetString("format")
|
||||
if !shortcutDeclaresFormatFlag(s) {
|
||||
canonicalFormat, err := canonicalizeFrameworkFormatFlag(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
formatName = canonicalFormat
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(formatName), output.FormatJSON.String()) {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--print-schema requires --format json").
|
||||
WithParam("--format").
|
||||
WithHint("rerun with --format json")
|
||||
}
|
||||
flagName, _ := cmd.Flags().GetString("flag-name")
|
||||
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
|
||||
if err != nil {
|
||||
// PrintFlagSchema implementations return bare errors; wrap as a
|
||||
// typed validation error so --print-schema (an agent-facing
|
||||
// introspection path) yields a parseable envelope, not a plain
|
||||
// string.
|
||||
if !errs.IsTyped(err) {
|
||||
err = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
|
||||
}
|
||||
@@ -960,13 +928,6 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
|
||||
if err := resolveInputFlags(rctx, s.Flags); err != nil {
|
||||
return err
|
||||
}
|
||||
if !shortcutDeclaresFormatFlag(s) {
|
||||
canonicalFormat, err := canonicalizeFrameworkFormatFlag(rctx.Cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.Format = canonicalFormat
|
||||
}
|
||||
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1170,13 +1131,8 @@ func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut)
|
||||
// Same data.context contract as the service/api dry-run paths.
|
||||
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
|
||||
}
|
||||
dryRunFormat := rctx.Format
|
||||
if !shortcutDeclaresFormatFlag(s) {
|
||||
format, _ := output.ParseFormat(rctx.Format)
|
||||
dryRunFormat = format.String()
|
||||
}
|
||||
return cmdutil.WriteDryRun(dryResult, cmdutil.DryRunOutputOptions{
|
||||
Format: dryRunFormat,
|
||||
Format: rctx.Format,
|
||||
JqExpr: rctx.JqExpr,
|
||||
CommandPath: rctx.Cmd.CommandPath(),
|
||||
Identity: rctx.As(),
|
||||
@@ -1216,35 +1172,6 @@ func shortcutDeclaresJSONFlag(s *Shortcut) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func shortcutDeclaresFormatFlag(s *Shortcut) bool {
|
||||
for _, fl := range s.Flags {
|
||||
if fl.Name == "format" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func canonicalizeFrameworkFormatFlag(cmd *cobra.Command) (string, error) {
|
||||
raw, err := cmd.Flags().GetString("format")
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to read the framework --format value").WithCause(err)
|
||||
}
|
||||
format, err := output.ParseFormatStrict(raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
canonical := format.String()
|
||||
if raw != canonical {
|
||||
if err := cmd.Flags().Set("format", canonical); err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"failed to canonicalize the framework --format value").WithCause(err)
|
||||
}
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
|
||||
// "json": a self-declared format supports it only when its Enum lists "json";
|
||||
// a framework-injected default format (no format entry in s.Flags) always does.
|
||||
|
||||
@@ -5,13 +5,9 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -41,113 +37,3 @@ func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
|
||||
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcutWritePrettyWithoutRendererExecutesAndUsesGenericTable(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
executeCalls := 0
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
writeStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/fixture/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "created"},
|
||||
},
|
||||
}
|
||||
reg.Register(writeStub)
|
||||
shortcut := &Shortcut{
|
||||
Service: "fixture",
|
||||
Command: "+write",
|
||||
Risk: "write",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
executeCalls++
|
||||
data, err := rctx.CallAPITyped("POST", "/open-apis/fixture/v1/items", nil, map[string]interface{}{"name": "created"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rctx.Out(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(shortcut, f)
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatalf("set --as: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("format", "pretty"); err != nil {
|
||||
t.Fatalf("set --format: %v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(cmd, f, shortcut, true); err != nil {
|
||||
t.Fatalf("runShortcut() error = %v, want nil", err)
|
||||
}
|
||||
if executeCalls != 1 {
|
||||
t.Fatalf("Execute call count = %d, want 1", executeCalls)
|
||||
}
|
||||
if len(writeStub.CapturedBodies) != 1 {
|
||||
t.Fatalf("API call count = %d, want 1", len(writeStub.CapturedBodies))
|
||||
}
|
||||
const wantStdout = "id created\n"
|
||||
if stdout.String() != wantStdout {
|
||||
t.Fatalf("stdout = %q, want %q", stdout.String(), wantStdout)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcutOutHonorsSelectedFormat(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
for _, format := range []string{"json", "pretty", "ndjson", "table", "csv"} {
|
||||
t.Run(format, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
shortcut := &Shortcut{
|
||||
Service: "fixture",
|
||||
Command: "+read",
|
||||
Risk: "read",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
rctx.Out([]interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(shortcut, f)
|
||||
if err := cmd.Flags().Set("as", "bot"); err != nil {
|
||||
t.Fatalf("set --as: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("format", format); err != nil {
|
||||
t.Fatalf("set --format: %v", err)
|
||||
}
|
||||
|
||||
if err := runShortcut(cmd, f, shortcut, true); err != nil {
|
||||
t.Fatalf("runShortcut() error = %v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if format == "json" {
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, got)
|
||||
}
|
||||
if envelope["ok"] != true {
|
||||
t.Fatalf("JSON envelope ok = %#v, want true", envelope["ok"])
|
||||
}
|
||||
return
|
||||
}
|
||||
if strings.Contains(got, `"ok"`) {
|
||||
t.Fatalf("%s output contains a JSON envelope: %s", format, got)
|
||||
}
|
||||
if !strings.Contains(got, "Alice") {
|
||||
t.Fatalf("%s output = %q, want rendered data", format, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,15 +143,6 @@ func TestRuntimeContext_OutRaw_PropagatesWriteError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeContext_OutRaw_HonorsSelectedFormat(t *testing.T) {
|
||||
rctx, stdout, _ := newJqTestContext("", "ndjson")
|
||||
rctx.OutRaw([]interface{}{map[string]interface{}{"html": "<p>hello</p>"}}, nil)
|
||||
|
||||
if got := stdout.String(); got != "{\"html\":\"\\u003cp\\u003ehello\\u003c/p\\u003e\"}\n" {
|
||||
t.Fatalf("OutRaw() stdout = %q, want NDJSON without an envelope", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_OutRawWriteErrorPropagates(t *testing.T) {
|
||||
sentinel := errors.New("write failed")
|
||||
f := newTestFactory()
|
||||
@@ -255,15 +246,12 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd := newTestShortcutCmd(s, newTestFactory())
|
||||
cmd.Flags().Set("jq", ".data")
|
||||
cmd.Flags().Set("format", "table")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, f, s, true)
|
||||
err := runShortcut(cmd, newTestFactory(), s, true)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for --jq + --format table conflict")
|
||||
}
|
||||
@@ -351,207 +339,6 @@ func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_DryRunMixedCasePrettyUsesPlainTextPreview(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
DryRun: func(context.Context, *RuntimeContext) *cmdutil.DryRunAPI {
|
||||
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Execute should not run in dry-run")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("dry-run", "true")
|
||||
cmd.Flags().Set("format", "Pretty")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
if err := runShortcut(cmd, f, s, false); err != nil {
|
||||
t.Fatalf("runShortcut() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
|
||||
t.Fatalf("dry-run --format Pretty lost its plain-text preview, stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_MixedCaseFrameworkFormatIsCanonicalized(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
var runtimeFormat string
|
||||
var flagFormat string
|
||||
prettyBranchFired := false
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
runtimeFormat = rctx.Format
|
||||
flagFormat = rctx.Str("format")
|
||||
prettyBranchFired = rctx.Format == "pretty"
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("format", "PRETTY")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
if err := runShortcut(cmd, f, s, false); err != nil {
|
||||
t.Fatalf("runShortcut() error = %v", err)
|
||||
}
|
||||
if runtimeFormat != "pretty" {
|
||||
t.Fatalf("RuntimeContext.Format = %q, want pretty", runtimeFormat)
|
||||
}
|
||||
if flagFormat != "pretty" {
|
||||
t.Fatalf("RuntimeContext.Str(\"format\") = %q, want pretty", flagFormat)
|
||||
}
|
||||
if !prettyBranchFired {
|
||||
t.Fatal("downstream RuntimeContext.Format == \"pretty\" branch did not fire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_UnknownFormatErrorIncludesPretty(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Execute should not run for an unknown format")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("format", "tabel")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, f, s, false)
|
||||
if err == nil {
|
||||
t.Fatal("expected a validation error for unknown --format")
|
||||
}
|
||||
validationErr := requireValidation(t, err, "unknown output format")
|
||||
if validationErr.Param != "--format" {
|
||||
t.Fatalf("Param = %q, want --format", validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "pretty") {
|
||||
t.Fatalf("shortcut unknown-format error = %v, want pretty in allowed choices", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_PrintSchemaRejectsUnknownFrameworkFormat(t *testing.T) {
|
||||
schemaCalled := false
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
PrintFlagSchema: func(string) ([]byte, error) {
|
||||
schemaCalled = true
|
||||
return []byte(`{"type":"object"}`), nil
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Execute should not run for --print-schema")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("print-schema", "true")
|
||||
cmd.Flags().Set("format", "tabel")
|
||||
|
||||
err := runShortcut(cmd, f, s, false)
|
||||
validationErr := requireValidation(t, err, "unknown output format")
|
||||
if validationErr.Param != "--format" {
|
||||
t.Fatalf("Param = %q, want --format", validationErr.Param)
|
||||
}
|
||||
if schemaCalled {
|
||||
t.Fatal("PrintFlagSchema should not run after an invalid framework --format")
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("invalid --format wrote schema to stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_PrintSchemaRejectsKnownNonJSONFormat(t *testing.T) {
|
||||
schemaCalled := false
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
PrintFlagSchema: func(string) ([]byte, error) {
|
||||
schemaCalled = true
|
||||
return []byte(`{"type":"object"}`), nil
|
||||
},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Execute should not run for --print-schema")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("print-schema", "true")
|
||||
cmd.Flags().Set("format", "csv")
|
||||
|
||||
err := runShortcut(cmd, f, s, false)
|
||||
validationErr := requireValidation(t, err, "requires --format json")
|
||||
if validationErr.Param != "--format" {
|
||||
t.Fatalf("Param = %q, want --format", validationErr.Param)
|
||||
}
|
||||
if schemaCalled {
|
||||
t.Fatal("PrintFlagSchema should not run for a non-JSON format")
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("non-JSON format wrote schema to stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_UnknownFormatPrecedesJqConflict(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(context.Context, *RuntimeContext) error {
|
||||
t.Fatal("Execute should not run for an unknown format")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
|
||||
})
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("format", "tabel")
|
||||
cmd.Flags().Set("jq", ".")
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, f, s, false)
|
||||
validationErr := requireValidation(t, err, "unknown output format")
|
||||
if validationErr.Param != "--format" {
|
||||
t.Fatalf("Param = %q, want --format", validationErr.Param)
|
||||
}
|
||||
if strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_DryRunWithJq(t *testing.T) {
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
|
||||
@@ -5,8 +5,8 @@ package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -16,14 +16,14 @@ import (
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestOutPartialFailure pins the batch / multi-status contract: stdout honors
|
||||
// the selected format and still carries the full payload, while the returned
|
||||
// error is the typed partial-failure exit signal.
|
||||
// TestOutPartialFailure pins the batch / multi-status contract: the result
|
||||
// rides on stdout as an ok:false envelope (carrying the full payload), and the
|
||||
// returned error is the typed partial-failure exit signal (ExitAPI), distinct
|
||||
// from ErrBare (the silent-exit signal).
|
||||
func TestOutPartialFailure(t *testing.T) {
|
||||
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
|
||||
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+push"}, cfg, f, core.AsUser)
|
||||
rt.Format = "table"
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"summary": map[string]interface{}{"uploaded": 1, "failed": 1},
|
||||
@@ -44,15 +44,20 @@ func TestOutPartialFailure(t *testing.T) {
|
||||
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
}
|
||||
|
||||
// 2) table output contains both successful and failed outcomes and does not
|
||||
// silently switch to a JSON envelope.
|
||||
got := stdout.String()
|
||||
for _, want := range []string{"a.txt", "uploaded", "b.txt", "failed", "boom"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
// 2) stdout envelope reports ok:false but still carries the full payload
|
||||
// (both the succeeded and failed items) — consistent with a success Out().
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if strings.Contains(got, `"ok"`) {
|
||||
t.Fatalf("table output contains JSON envelope:\n%s", got)
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
if env.OK {
|
||||
t.Errorf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String())
|
||||
}
|
||||
items, _ := env.Data["items"].([]interface{})
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) {
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "XML str_replace rejects multiline pattern",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "block_delete without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects empty ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects duplicate ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_insert_after without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
|
||||
|
||||
@@ -17,6 +17,46 @@ import (
|
||||
|
||||
// ── V2 (OpenAPI) tests ──
|
||||
|
||||
func TestStripTopLevelXMLTitles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single title",
|
||||
content: "<title>Content title</title><p>body</p>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "multiple titles",
|
||||
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "nested title is preserved",
|
||||
content: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
want: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "malformed XML is preserved",
|
||||
content: "<title>Content title</title><p>A & B</p>",
|
||||
want: "<title>Content title</title><p>A & B</p>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
|
||||
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -16,7 +18,7 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
@@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
if title == "" {
|
||||
return content
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" {
|
||||
content = stripTopLevelXMLTitles(content)
|
||||
}
|
||||
|
||||
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
|
||||
if content == "" {
|
||||
@@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
return titleTag + "\n" + content
|
||||
}
|
||||
|
||||
type docContentRange struct {
|
||||
start int64
|
||||
end int64
|
||||
}
|
||||
|
||||
// stripTopLevelXMLTitles preserves the established --title-wins contract while
|
||||
// avoiding duplicate-title warnings from XML content. If the fragment is not
|
||||
// well-formed XML, it is left untouched for the service to diagnose.
|
||||
func stripTopLevelXMLTitles(content string) string {
|
||||
const wrapperStart = "<root>"
|
||||
wrapped := wrapperStart + content + "</root>"
|
||||
decoder := xml.NewDecoder(strings.NewReader(wrapped))
|
||||
wrapperLen := int64(len(wrapperStart))
|
||||
depth := 0
|
||||
activeStart := int64(-1)
|
||||
ranges := make([]docContentRange, 0, 1)
|
||||
|
||||
for {
|
||||
tokenStart := decoder.InputOffset()
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
activeStart = tokenStart - wrapperLen
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
|
||||
activeStart = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ranges) == 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
cursor := int64(0)
|
||||
for _, item := range ranges {
|
||||
result.WriteString(content[int(cursor):int(item.start)])
|
||||
cursor = item.end
|
||||
}
|
||||
result.WriteString(content[int(cursor):])
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func escapeDocTitleText(title string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(title))
|
||||
|
||||
@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
}
|
||||
@@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if pattern == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
|
||||
}
|
||||
case "block_delete":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if err := validateBlockDeleteIDs(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "block_insert_after":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
|
||||
@@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBlockDeleteIDs(raw string) error {
|
||||
seen := make(map[string]struct{})
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
blockID := strings.TrimSpace(part)
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeBlockDeleteIDs(raw string) string {
|
||||
parts := strings.Split(raw, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["pattern"] = v
|
||||
}
|
||||
if blockID != "" {
|
||||
if cmd == "block_delete" {
|
||||
blockID = normalizeBlockDeleteIDs(blockID)
|
||||
}
|
||||
body["block_id"] = blockID
|
||||
}
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
@@ -28,7 +27,6 @@ const dedupTTL = 5 * time.Minute
|
||||
type PipelineConfig struct {
|
||||
Mode TransformMode // determined by --compact flag
|
||||
JsonFlag bool // --json: pretty JSON instead of NDJSON
|
||||
Format string // explicit stdout format: json or ndjson
|
||||
OutputDir string // --output-dir: write events to files
|
||||
Quiet bool // --quiet: suppress stderr status messages
|
||||
Router *EventRouter // --route: regex-based output routing
|
||||
@@ -41,8 +39,7 @@ type EventPipeline struct {
|
||||
config PipelineConfig
|
||||
eventCount atomic.Int64
|
||||
seen sync.Map // key → time.Time (first-seen timestamp)
|
||||
emitMu sync.Mutex
|
||||
emitter *output.Emitter
|
||||
out io.Writer
|
||||
errOut io.Writer
|
||||
}
|
||||
|
||||
@@ -53,17 +50,12 @@ func NewEventPipeline(
|
||||
config PipelineConfig,
|
||||
out, errOut io.Writer,
|
||||
) *EventPipeline {
|
||||
commandPath := "lark-cli event +subscribe"
|
||||
return &EventPipeline{
|
||||
registry: registry,
|
||||
filters: filters,
|
||||
config: config,
|
||||
emitter: output.NewEmitter(output.EmitterConfig{
|
||||
Out: out,
|
||||
ErrOut: errOut,
|
||||
CommandPath: commandPath,
|
||||
}),
|
||||
errOut: errOut,
|
||||
out: out,
|
||||
errOut: errOut,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +109,12 @@ func (p *EventPipeline) cleanupSeen(now time.Time) {
|
||||
}
|
||||
|
||||
// Process is the pipeline entry point, called by the WebSocket callback.
|
||||
func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) error {
|
||||
func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) {
|
||||
eventType := raw.Header.EventType
|
||||
|
||||
// 1. Filter
|
||||
if !p.filters.Allow(eventType) {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Lookup processor
|
||||
@@ -131,7 +123,7 @@ func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) error {
|
||||
// 3. Dedup
|
||||
if key := processor.DeduplicateKey(raw); key != "" && p.isDuplicate(key) {
|
||||
p.infof("%s[dedup]%s %s (key=%s)", output.Dim, output.Reset, eventType, key)
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
n := p.eventCount.Add(1)
|
||||
@@ -149,34 +141,23 @@ func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) error {
|
||||
for _, dir := range dirs {
|
||||
p.writeAndLog(dir, n, eventType, data, raw.Header)
|
||||
}
|
||||
return nil
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 5b. --output-dir
|
||||
if p.config.OutputDir != "" {
|
||||
p.writeAndLog(p.config.OutputDir, n, eventType, data, raw.Header)
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
// 5c. Stdout
|
||||
format := output.FormatNDJSON
|
||||
switch {
|
||||
case p.config.JsonFlag || p.config.Format == output.FormatJSON.String():
|
||||
format = output.FormatJSON
|
||||
case p.config.Format == "", p.config.Format == output.FormatNDJSON.String():
|
||||
default:
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"internal: unsupported event pipeline format %q", p.config.Format)
|
||||
}
|
||||
p.emitMu.Lock()
|
||||
err := p.emitter.Value(data, output.StreamOptions{Format: format})
|
||||
p.emitMu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
if p.config.JsonFlag {
|
||||
output.PrintJson(p.out, data)
|
||||
} else {
|
||||
output.PrintNdjson(p.out, data)
|
||||
}
|
||||
p.infof("%s[%d]%s %s", output.Dim, n, output.Reset, eventType)
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeAndLog writes an event to a directory and logs the result.
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -18,7 +17,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/lockfile"
|
||||
@@ -27,24 +25,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type eventBlockingSafetyProvider struct{}
|
||||
|
||||
func (eventBlockingSafetyProvider) Name() string { return "event-test" }
|
||||
|
||||
func (eventBlockingSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
encoded, _ := json.Marshal(req.Data)
|
||||
var normalized any
|
||||
_ = json.Unmarshal(encoded, &normalized)
|
||||
if strings.Contains(fmt.Sprint(normalized), "<system>") {
|
||||
return &extcs.Alert{Provider: "event-test", MatchedRules: []string{"role-injection"}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (p eventBlockingSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.Scan(ctx, req)
|
||||
}
|
||||
|
||||
// chdirTemp changes cwd to a fresh temp dir for the test duration.
|
||||
func chdirTemp(t *testing.T) {
|
||||
t.Helper()
|
||||
@@ -620,26 +600,6 @@ func TestPipeline_JsonFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPipeline_BlockModeScansEventBeforeStdout(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(eventBlockingSafetyProvider{})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
filters := NewFilterChain()
|
||||
var out, errOut bytes.Buffer
|
||||
p := NewEventPipeline(DefaultRegistry(), filters,
|
||||
PipelineConfig{Mode: TransformRaw, Format: "ndjson"}, &out, &errOut)
|
||||
|
||||
err := p.Process(context.Background(), makeRawEvent("drive.file.edit_v1", `{"text":"<system>"}`))
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("Process() error = %T, want *errs.ContentSafetyError", err)
|
||||
}
|
||||
if out.Len() != 0 {
|
||||
t.Fatalf("Process() stdout = %q, want empty", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pipeline: Quiet ---
|
||||
|
||||
func TestPipeline_Quiet(t *testing.T) {
|
||||
|
||||
@@ -98,8 +98,7 @@ var EventSubscribe = common.Shortcut{
|
||||
{Name: "route", Type: "string_array", Desc: "regex-based event routing (e.g. --route '^im\\.message=dir:./im/' --route '^contact\\.=dir:./contacts/'); unmatched events fall through to --output-dir or stdout"},
|
||||
// Output format — how events are serialized
|
||||
{Name: "compact", Type: "bool", Desc: "flat key-value output: extract text, strip noise fields"},
|
||||
{Name: "format", Default: "ndjson", Enum: []string{"json", "ndjson"}, Desc: "stdout format: json (pretty-printed event objects) or ndjson (one compact object per line)"},
|
||||
{Name: "json", Type: "bool", Desc: "alias for --format json"},
|
||||
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
|
||||
// Filtering — which events reach the pipeline
|
||||
{Name: "event-types", Desc: "comma-separated event types to subscribe; only use when you do not need other events (omit for catch-all)"},
|
||||
{Name: "filter", Desc: "regex to further filter events by event_type"},
|
||||
@@ -107,17 +106,6 @@ var EventSubscribe = common.Shortcut{
|
||||
{Name: "quiet", Type: "bool", Desc: "suppress stderr status messages"},
|
||||
{Name: "force", Type: "bool", Desc: "bypass single-instance lock (UNSAFE: server randomly splits events across connections, each instance only receives a subset)"},
|
||||
},
|
||||
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Bool("json") &&
|
||||
runtime.Cmd.Flags().Changed("format") &&
|
||||
runtime.Str("format") != output.FormatJSON.String() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument,
|
||||
"--json conflicts with --format %s", runtime.Str("format")).
|
||||
WithParam("--format").
|
||||
WithHint("use --format json or remove --json")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
eventTypesDisplay := "(catch-all)"
|
||||
if s := runtime.Str("event-types"); s != "" {
|
||||
@@ -135,27 +123,18 @@ var EventSubscribe = common.Shortcut{
|
||||
if routes := runtime.StrArray("route"); len(routes) > 0 {
|
||||
routeDisplay = strings.Join(routes, "; ")
|
||||
}
|
||||
formatDisplay := runtime.Str("format")
|
||||
if runtime.Bool("json") {
|
||||
formatDisplay = output.FormatJSON.String()
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Subscribe to Lark events via WebSocket (long-running)").
|
||||
Set("command", "event +subscribe").
|
||||
Set("app_id", runtime.Config.AppID).
|
||||
Set("event_types", eventTypesDisplay).
|
||||
Set("filter", filterDisplay).Set("output_dir", outputDirDisplay).
|
||||
Set("route", routeDisplay).
|
||||
Set("format", formatDisplay)
|
||||
Set("route", routeDisplay)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
eventTypesStr := runtime.Str("event-types")
|
||||
filterStr := runtime.Str("filter")
|
||||
jsonFlag := runtime.Bool("json")
|
||||
formatName := runtime.Str("format")
|
||||
if jsonFlag {
|
||||
formatName = output.FormatJSON.String()
|
||||
}
|
||||
compactFlag := runtime.Bool("compact")
|
||||
outputDir := runtime.Str("output-dir")
|
||||
quietFlag := runtime.Bool("quiet")
|
||||
@@ -228,7 +207,7 @@ var EventSubscribe = common.Shortcut{
|
||||
}
|
||||
pipeline := NewEventPipeline(DefaultRegistry(), filters, PipelineConfig{
|
||||
Mode: mode,
|
||||
Format: formatName,
|
||||
JsonFlag: jsonFlag,
|
||||
OutputDir: outputDir,
|
||||
Quiet: quietFlag,
|
||||
Router: router,
|
||||
@@ -248,7 +227,8 @@ var EventSubscribe = common.Shortcut{
|
||||
output.PrintError(errOut, fmt.Sprintf("failed to parse event: %v", err))
|
||||
return nil
|
||||
}
|
||||
return pipeline.Process(ctx, &raw)
|
||||
pipeline.Process(ctx, &raw)
|
||||
return nil
|
||||
}
|
||||
|
||||
sdkLogger := &stderrLogger{w: errOut, quiet: quietFlag}
|
||||
|
||||
@@ -210,7 +210,7 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
|
||||
|
||||
// printWatchOutputSchema prints the per-format field reference for +watch output.
|
||||
// Used by --print-output-schema to let callers discover field names without reading skill docs.
|
||||
func printWatchOutputSchema(runtime *common.RuntimeContext) error {
|
||||
func printWatchOutputSchema(runtime *common.RuntimeContext) {
|
||||
schema := map[string]interface{}{
|
||||
"minimal": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
@@ -276,7 +276,8 @@ func printWatchOutputSchema(runtime *common.RuntimeContext) error {
|
||||
},
|
||||
},
|
||||
}
|
||||
return runtime.EmitValue(schema, "json")
|
||||
b, _ := json.MarshalIndent(schema, "", " ")
|
||||
fmt.Fprintln(runtime.IO().Out, string(b))
|
||||
}
|
||||
|
||||
// resolveMailboxID returns the user_mailbox_id from --mailbox flag, defaulting to "me".
|
||||
|
||||
@@ -421,7 +421,7 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) {
|
||||
`{"folder":"SENT","time_range":{"start_time":"2026-03-01T00:00:00+08:00"}}`,
|
||||
},
|
||||
}
|
||||
runtime.OutJSON(schema, nil)
|
||||
runtime.Out(schema, nil)
|
||||
}
|
||||
|
||||
func parseTriageFilter(filterStr string) (triageFilter, error) {
|
||||
|
||||
@@ -834,17 +834,12 @@ func TestMergeTriageLabels(t *testing.T) {
|
||||
|
||||
func TestPrintTriageFilterSchema(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
rt.Format = "data"
|
||||
var buf strings.Builder
|
||||
rt.Factory = &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: &buf, ErrOut: &buf}}
|
||||
printTriageFilterSchema(rt)
|
||||
if !strings.Contains(buf.String(), "folder") {
|
||||
t.Fatal("schema output should contain 'folder'")
|
||||
}
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(buf.String()), &envelope); err != nil {
|
||||
t.Fatalf("schema output is not a JSON envelope: %v\n%s", err, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- resolveSearchFolderFilter / resolveSearchLabelFilter (dry-run) ---
|
||||
|
||||
@@ -179,7 +179,8 @@ var MailWatch = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Bool("print-output-schema") {
|
||||
return printWatchOutputSchema(runtime)
|
||||
printWatchOutputSchema(runtime)
|
||||
return nil
|
||||
}
|
||||
mailbox := resolveMailboxID(runtime)
|
||||
hintIdentityFirst(runtime, mailbox)
|
||||
@@ -216,6 +217,8 @@ var MailWatch = common.Shortcut{
|
||||
foldersInput := runtime.Str("folders")
|
||||
|
||||
errOut := runtime.IO().ErrOut
|
||||
out := runtime.IO().Out
|
||||
|
||||
info := func(msg string) {
|
||||
fmt.Fprintln(errOut, msg)
|
||||
}
|
||||
@@ -283,7 +286,7 @@ var MailWatch = common.Shortcut{
|
||||
|
||||
var eventCount atomic.Int64
|
||||
|
||||
handleEvent := func(data map[string]interface{}) error {
|
||||
handleEvent := func(data map[string]interface{}) {
|
||||
// Extract event body
|
||||
eventBody := extractMailEventBody(data)
|
||||
|
||||
@@ -291,13 +294,13 @@ var MailWatch = common.Shortcut{
|
||||
if mailboxFilter != "" {
|
||||
mailAddr, _ := eventBody["mail_address"].(string)
|
||||
if !strings.EqualFold(mailAddr, mailboxFilter) {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
messageID, _ := eventBody["message_id"].(string)
|
||||
if messageID == "" {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
// Use event's mail_address as the fetch mailbox when available,
|
||||
@@ -329,7 +332,8 @@ var MailWatch = common.Shortcut{
|
||||
output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", writeErr))
|
||||
}
|
||||
}
|
||||
return runtime.EmitValue(failureData, output.FormatNDJSON.String())
|
||||
output.PrintJson(out, failureData)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,12 +341,12 @@ var MailWatch = common.Shortcut{
|
||||
if len(folderIDSet) > 0 {
|
||||
folderID, _ := message["folder_id"].(string)
|
||||
if !folderIDSet[folderID] {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(labelIDSet) > 0 {
|
||||
if !messageHasLabel(message, labelIDSet) {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,14 +387,10 @@ var MailWatch = common.Shortcut{
|
||||
|
||||
switch outFormat {
|
||||
case "json", "":
|
||||
return runtime.EmitValue(
|
||||
output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData},
|
||||
output.FormatNDJSON.String(),
|
||||
)
|
||||
output.PrintNdjson(out, output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData})
|
||||
case "data":
|
||||
return runtime.EmitValue(outputData, output.FormatNDJSON.String())
|
||||
output.PrintNdjson(out, outputData)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
rawHandler := func(ctx context.Context, event *larkevent.EventReq) error {
|
||||
@@ -405,7 +405,8 @@ var MailWatch = common.Shortcut{
|
||||
if eventData == nil {
|
||||
eventData = make(map[string]interface{})
|
||||
}
|
||||
return handleEvent(eventData)
|
||||
handleEvent(eventData)
|
||||
return nil
|
||||
}
|
||||
|
||||
sdkLogger := &mailWatchLogger{w: errOut}
|
||||
|
||||
@@ -329,7 +329,7 @@ var MinutesSearch = common.Shortcut{
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
if hasMore && runtime.Format != "json" && runtime.Format != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n(more available, page_token: %s)\n", pageToken)
|
||||
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -476,7 +476,7 @@ func TestMinutesSearchDryRun(t *testing.T) {
|
||||
|
||||
// TestMinutesSearchExecuteRendersRowsAndMoreHint verifies pretty output renders rows and pagination hints.
|
||||
func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
@@ -526,16 +526,11 @@ func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123"} {
|
||||
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "next_token", "more available"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("output missing %q, got: %s", want, out)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"next_token", "more available"} {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("stderr missing %q, got: %s", want, stderr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteNoMinutes verifies empty results render the no-data message.
|
||||
@@ -568,12 +563,11 @@ func TestMinutesSearchExecuteNoMinutes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies pagination
|
||||
// hints stay on stderr so table stdout remains parseable.
|
||||
// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies table output includes pagination hints.
|
||||
func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/minutes/v1/minutes/search",
|
||||
@@ -605,11 +599,9 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
if strings.Contains(stdout.String(), "next_token") || strings.Contains(stdout.String(), "more available") {
|
||||
t.Fatalf("table stdout contains pagination hint: %s", stdout.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "next_token") || !strings.Contains(stderr.String(), "more available") {
|
||||
t.Fatalf("stderr missing pagination hint: %s", stderr.String())
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "next_token") || !strings.Contains(out, "more available") {
|
||||
t.Fatalf("expected pagination hint in table output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ var VCSearch = common.Shortcut{
|
||||
// 非 json 格式下追加分页提示(json 格式已包含 has_more/page_token 字段)
|
||||
if hasMore && runtime.Format != "json" && runtime.Format != "" {
|
||||
pt, _ := data["page_token"].(string)
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "\n(more available, page_token: %s)\n", pt)
|
||||
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pt)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
)
|
||||
|
||||
func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
|
||||
clie2e.SkipWithoutTenantAccessToken(t)
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
@@ -44,7 +44,6 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
|
||||
"--doc", docToken,
|
||||
"--doc-format", "markdown",
|
||||
},
|
||||
DefaultAs: defaultAs,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -91,10 +91,11 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
||||
"docs", "+update",
|
||||
"--doc", "doxcnDryRunE2E",
|
||||
"--command", "block_delete",
|
||||
"--block-id", "blkA,blkB,blkC",
|
||||
"--block-id", "blkA, blkB, blkC",
|
||||
"--dry-run",
|
||||
},
|
||||
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||
wantBody: map[string]any{"block_id": "blkA,blkB,blkC"},
|
||||
},
|
||||
{
|
||||
name: "history list",
|
||||
@@ -225,3 +226,60 @@ func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
|
||||
require.Equal(t, "markdown", clie2e.DryRunGet(out, "api.0.body.format").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "<title>Dry Run & Title</title>\n## Body", clie2e.DryRunGet(out, "api.0.body.content").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
func TestDocs_CreateTitleDryRunNormalizesXMLTitle(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"docs", "+create",
|
||||
"--title", "Flag title",
|
||||
"--content", "<title>Content title</title><p>body</p>",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.Equal(t, "<title>Flag title</title>\n<p>body</p>", clie2e.DryRunGet(result.Stdout, "api.0.body.content").String())
|
||||
}
|
||||
|
||||
func TestDocs_DryRunRejectsUnsafeWriteInputs(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "multiline XML str_replace",
|
||||
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "str_replace", "--pattern", "line one\nline two", "--content", "replacement", "--dry-run"},
|
||||
want: "must be inline",
|
||||
},
|
||||
{
|
||||
name: "duplicate block delete ID",
|
||||
args: []string{"docs", "+update", "--doc", "doxcnDryRunE2E", "--command", "block_delete", "--block-id", "blkA,blkA", "--dry-run"},
|
||||
want: "duplicate ID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{Args: tt.args, DefaultAs: "bot"})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Contains(t, result.Stdout+"\n"+result.Stderr, tt.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ func TestEventSubscribeDryRun(t *testing.T) {
|
||||
"--filter", "^im\\.",
|
||||
"--output-dir", "events_out",
|
||||
"--route", "^im\\.message=dir:./messages",
|
||||
"--format", "json",
|
||||
"--dry-run",
|
||||
},
|
||||
DefaultAs: "bot",
|
||||
@@ -43,5 +42,4 @@ func TestEventSubscribeDryRun(t *testing.T) {
|
||||
require.Equal(t, "^im\\.", clie2e.DryRunGet(out, "filter").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "events_out", clie2e.DryRunGet(out, "output_dir").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "^im\\.message=dir:./messages", clie2e.DryRunGet(out, "route").String(), "stdout:\n%s", out)
|
||||
require.Equal(t, "json", clie2e.DryRunGet(out, "format").String(), "stdout:\n%s", out)
|
||||
}
|
||||
|
||||
@@ -366,7 +366,6 @@ func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suf
|
||||
"--cycle-id", cycleID,
|
||||
"--input", string(inputJSON),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err, "failed to create test objectives")
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -412,7 +411,6 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
|
||||
"--key-result-id", krID,
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
|
||||
select {
|
||||
@@ -428,7 +426,6 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
|
||||
"--objective-id", obj.ObjectiveID,
|
||||
"--yes",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
|
||||
if i > 0 {
|
||||
@@ -450,7 +447,6 @@ func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suff
|
||||
"--cycle-id", cycleID,
|
||||
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err, "failed to create live objective")
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -470,7 +466,6 @@ func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string,
|
||||
"--objective-id", objectiveID,
|
||||
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err, "failed to create live key result")
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -504,7 +499,6 @@ func TestOKR_BatchCreateLive(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -550,7 +544,6 @@ func TestOKR_CreateLive_Objective(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -588,7 +581,6 @@ func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -648,7 +640,6 @@ func TestOKR_ReorderLive(t *testing.T) {
|
||||
"--level", "objective",
|
||||
"--ops", string(opsJSON),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -659,7 +650,6 @@ func TestOKR_ReorderLive(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -712,7 +702,6 @@ func TestOKR_WeightLive(t *testing.T) {
|
||||
"--level", "objective",
|
||||
"--weights", string(weightsJSON),
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
@@ -723,7 +712,6 @@ func TestOKR_WeightLive(t *testing.T) {
|
||||
"okr", "+cycle-detail",
|
||||
"--cycle-id", cycleID,
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func setTaskFormatDryRunEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "task_format_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_format_dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
|
||||
func TestTaskDryRunMixedCasePrettyUsesPlainTextPreview(t *testing.T) {
|
||||
setTaskFormatDryRunEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"task", "+get-my-tasks",
|
||||
"--dry-run",
|
||||
"--format", "Pretty",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, result.RunErr, "stderr:\n%s", result.Stderr)
|
||||
result.AssertExitCode(t, 0)
|
||||
require.True(t, strings.HasPrefix(result.Stdout, "# dry-run: request not sent\n"), "stdout:\n%s", result.Stdout)
|
||||
require.Contains(t, result.Stdout, "/open-apis/task/v2/tasks", "stdout:\n%s", result.Stdout)
|
||||
require.False(t, strings.HasPrefix(strings.TrimSpace(result.Stdout), "{"), "stdout must be plain text:\n%s", result.Stdout)
|
||||
}
|
||||
|
||||
func TestTaskDryRunUnknownFormatReturnsTypedValidationBeforePreview(t *testing.T) {
|
||||
setTaskFormatDryRunEnv(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"task", "+get-my-tasks",
|
||||
"--dry-run",
|
||||
"--format", "yaml",
|
||||
},
|
||||
DefaultAs: "user",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Error(t, result.RunErr)
|
||||
result.AssertExitCode(t, 2)
|
||||
require.Empty(t, strings.TrimSpace(result.Stdout), "request preview must not be emitted:\n%s", result.Stdout)
|
||||
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
|
||||
require.Equal(t, "--format", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
|
||||
}
|
||||
Reference in New Issue
Block a user