mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
413 lines
14 KiB
Go
413 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package schema
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/internal/apicatalog"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/meta"
|
|
)
|
|
|
|
func TestSchemaCmd_FlagParsing(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
var gotOpts *SchemaOptions
|
|
cmd := NewCmdSchema(f, func(opts *SchemaOptions) error {
|
|
gotOpts = opts
|
|
return nil
|
|
})
|
|
cmd.SetArgs([]string{"calendar.events.list"})
|
|
err := cmd.Execute()
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" {
|
|
t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args)
|
|
}
|
|
}
|
|
|
|
func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) {
|
|
// Agents are habituated to --format/--json/--as from api/service commands.
|
|
// schema must accept them without erroring and always emit the JSON envelope —
|
|
// its output is structured JSON and identity-independent, so the values have
|
|
// no effect.
|
|
argSets := [][]string{
|
|
{"--format", "json"},
|
|
{"--format", "pretty"},
|
|
{"--format", "table"}, // no table rendering for a nested schema -> JSON
|
|
{"--format", "csv"},
|
|
{"--json"},
|
|
{"--json", "--format", "ndjson"},
|
|
{"--as", "user"},
|
|
{"--as", "bot"},
|
|
{"--as", "user", "--json"},
|
|
}
|
|
for _, extra := range argSets {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
cmd := NewCmdSchema(f, nil)
|
|
cmd.SetArgs(append([]string{"im.images.create"}, extra...))
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("args %v should be accepted, got error: %v", extra, err)
|
|
}
|
|
var env map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String())
|
|
}
|
|
if env["name"] != "im images create" {
|
|
t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
cmd := NewCmdSchema(f, nil)
|
|
cmd.SetArgs([]string{})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
out := strings.TrimSpace(stdout.String())
|
|
if !strings.HasPrefix(out, "[") {
|
|
head := out
|
|
if len(head) > 80 {
|
|
head = head[:80]
|
|
}
|
|
t.Errorf("expected JSON array root, first 80 chars:\n%s", head)
|
|
}
|
|
var envs []map[string]interface{}
|
|
if err := json.Unmarshal([]byte(out), &envs); err != nil {
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
}
|
|
if len(envs) < 193 {
|
|
t.Errorf("envelopes count = %d, want >= 193", len(envs))
|
|
}
|
|
}
|
|
|
|
func TestSchemaCmd_JSONIsEnvelope(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
cmd := NewCmdSchema(f, nil)
|
|
cmd.SetArgs([]string{"im.images.create"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var env map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("not valid JSON: %v\n%s", err, stdout.String())
|
|
}
|
|
if env["name"] != "im images create" {
|
|
t.Errorf("name = %v, want \"im images create\"", env["name"])
|
|
}
|
|
for _, key := range []string{"description", "inputSchema", "outputSchema", "_meta"} {
|
|
if _, ok := env[key]; !ok {
|
|
t.Errorf("missing top-level key: %s", key)
|
|
}
|
|
}
|
|
meta, _ := env["_meta"].(map[string]interface{})
|
|
if meta["envelope_version"] != "1.0" {
|
|
t.Errorf("envelope_version = %v, want \"1.0\"", meta["envelope_version"])
|
|
}
|
|
}
|
|
|
|
func TestSchemaCmd_SpaceSeparatedPath_EqualsDotted(t *testing.T) {
|
|
f1, out1, _, _ := cmdutil.TestFactory(t, nil)
|
|
cmd1 := NewCmdSchema(f1, nil)
|
|
cmd1.SetArgs([]string{"im", "images", "create"})
|
|
if err := cmd1.Execute(); err != nil {
|
|
t.Fatalf("space form failed: %v", err)
|
|
}
|
|
|
|
f2, out2, _, _ := cmdutil.TestFactory(t, nil)
|
|
cmd2 := NewCmdSchema(f2, nil)
|
|
cmd2.SetArgs([]string{"im.images.create"})
|
|
if err := cmd2.Execute(); err != nil {
|
|
t.Fatalf("dotted form failed: %v", err)
|
|
}
|
|
|
|
if out1.String() != out2.String() {
|
|
t.Errorf("space and dotted forms produced different output")
|
|
}
|
|
}
|
|
|
|
func TestSchemaCmd_ServiceListIsArray(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
cmd := NewCmdSchema(f, nil)
|
|
cmd.SetArgs([]string{"im"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var envs []map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &envs); err != nil {
|
|
t.Fatalf("unmarshal failed: %v\n%s", err, stdout.String())
|
|
}
|
|
if len(envs) == 0 {
|
|
t.Fatal("expected non-empty array for service im")
|
|
}
|
|
for _, e := range envs {
|
|
name, _ := e["name"].(string)
|
|
if !strings.HasPrefix(name, "im ") {
|
|
t.Errorf("envelope name %q does not start with \"im \"", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSchemaCmd_HighRiskYesInjection(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
cmd := NewCmdSchema(f, nil)
|
|
cmd.SetArgs([]string{"im.messages.delete"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var env map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
}
|
|
is, _ := env["inputSchema"].(map[string]interface{})
|
|
props, _ := is["properties"].(map[string]interface{})
|
|
if _, ok := props["yes"]; !ok {
|
|
t.Errorf("inputSchema.properties.yes missing for high-risk-write command")
|
|
}
|
|
}
|
|
|
|
func TestSchemaCmd_NoYesForReadRisk(t *testing.T) {
|
|
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
|
|
|
|
cmd := NewCmdSchema(f, nil)
|
|
cmd.SetArgs([]string{"im.reactions.list"})
|
|
if err := cmd.Execute(); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
var env map[string]interface{}
|
|
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
|
t.Fatalf("unmarshal failed: %v", err)
|
|
}
|
|
is, _ := env["inputSchema"].(map[string]interface{})
|
|
props, _ := is["properties"].(map[string]interface{})
|
|
if _, ok := props["yes"]; ok {
|
|
t.Errorf("yes property should not appear for risk=read command")
|
|
}
|
|
}
|
|
|
|
func TestSchemaCmd_UnknownService(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdSchema(f, nil)
|
|
cmd.SetArgs([]string{"nonexistent_service"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Error("expected error for unknown service")
|
|
}
|
|
if !strings.Contains(err.Error(), "Unknown service") {
|
|
t.Errorf("expected 'Unknown service' error, got: %v", err)
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if !strings.Contains(ve.Hint, "Available:") {
|
|
t.Errorf("expected hint listing available services, got: %q", ve.Hint)
|
|
}
|
|
}
|
|
|
|
// TestSchemaCmd_UnknownMethod_TypedValidation pins the typed envelope for the
|
|
// JSON-mode unknown-method path: *errs.ValidationError with
|
|
// subtype invalid_argument and a hint listing the available methods.
|
|
func TestSchemaCmd_UnknownMethod_TypedValidation(t *testing.T) {
|
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
|
})
|
|
|
|
cmd := NewCmdSchema(f, nil)
|
|
cmd.SetArgs([]string{"calendar.events.nonexistent_method"})
|
|
err := cmd.Execute()
|
|
if err == nil {
|
|
t.Fatal("expected error for unknown method")
|
|
}
|
|
var ve *errs.ValidationError
|
|
if !errors.As(err, &ve) {
|
|
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
|
|
}
|
|
if ve.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
|
}
|
|
if !strings.Contains(err.Error(), "Unknown method") {
|
|
t.Errorf("expected 'Unknown method' error, got: %v", err)
|
|
}
|
|
if !strings.Contains(ve.Hint, "Available:") {
|
|
t.Errorf("expected hint listing available methods, got: %q", ve.Hint)
|
|
}
|
|
}
|
|
|
|
// Base completion navigation (dotted + space forms, strict-mode filtering,
|
|
// dotted-resource handling) lives in internal/apicatalog. The tests below pin
|
|
// cmd/schema's build-local surface projection around that navigator.
|
|
|
|
func TestSchemaSurfaceProjectionFiltersExecutionListingAndCompletion(t *testing.T) {
|
|
catalog := schemaSurfaceCatalog()
|
|
visible := func(path []string) bool {
|
|
return strings.Join(path, "/") != "mail/user_mailbox.messages/list"
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
if err := runSchemaCatalog(&out, nil, core.StrictModeOff, catalog, visible); err != nil {
|
|
t.Fatalf("broad schema failed: %v", err)
|
|
}
|
|
var envelopes []map[string]interface{}
|
|
if err := json.Unmarshal(out.Bytes(), &envelopes); err != nil {
|
|
t.Fatalf("broad schema output is not JSON: %v\n%s", err, out.String())
|
|
}
|
|
names := make(map[string]bool, len(envelopes))
|
|
for _, envelope := range envelopes {
|
|
name, _ := envelope["name"].(string)
|
|
names[name] = true
|
|
}
|
|
if names["mail user_mailbox.messages list"] {
|
|
t.Error("broad schema retained concealed mail messages list")
|
|
}
|
|
for _, want := range []string{"mail user_mailbox.messages get", "im messages list"} {
|
|
if !names[want] {
|
|
t.Errorf("broad schema lost visible method %q: %v", want, names)
|
|
}
|
|
}
|
|
|
|
out.Reset()
|
|
err := runSchemaCatalog(
|
|
&out,
|
|
[]string{"mail", "user_mailbox", "messages", "list"},
|
|
core.StrictModeOff,
|
|
catalog,
|
|
visible,
|
|
)
|
|
if err == nil {
|
|
t.Fatal("concealed exact method unexpectedly resolved")
|
|
}
|
|
var validationErr *errs.ValidationError
|
|
if !errors.As(err, &validationErr) || validationErr.Subtype != errs.SubtypeInvalidArgument {
|
|
t.Fatalf("concealed exact method error = %T %v, want validation/invalid_argument", err, err)
|
|
}
|
|
if strings.Contains(validationErr.Hint, "list") || !strings.Contains(validationErr.Hint, "get") {
|
|
t.Errorf("resolve candidates were not surface-projected: %q", validationErr.Hint)
|
|
}
|
|
if out.Len() != 0 {
|
|
t.Errorf("concealed exact method wrote schema output: %s", out.String())
|
|
}
|
|
|
|
projected := projectSchemaCatalog(catalog, visible)
|
|
if got, _ := projected.Complete(nil, "mail.user_mailbox.messages.l", nil); len(got) != 0 {
|
|
t.Errorf("dotted completion exposed concealed method: %v", got)
|
|
}
|
|
if got, _ := projected.Complete(nil, "mail.user_mailbox.messages.g", nil); !reflect.DeepEqual(got, []string{"mail.user_mailbox.messages.get"}) {
|
|
t.Errorf("dotted completion lost visible sibling: %v", got)
|
|
}
|
|
if got, _ := projected.Complete([]string{"mail", "user_mailbox", "messages"}, "l", nil); len(got) != 0 {
|
|
t.Errorf("space completion exposed concealed method: %v", got)
|
|
}
|
|
if got, _ := projected.Complete([]string{"mail", "user_mailbox", "messages"}, "g", nil); !reflect.DeepEqual(got, []string{"get"}) {
|
|
t.Errorf("space completion lost visible sibling: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestSchemaSurfaceProjectionDropsServiceWhenGlobConcealsAllDescendants(t *testing.T) {
|
|
catalog := schemaSurfaceCatalog()
|
|
// Mirrors a policy that retains the top-level schema command and mail group
|
|
// but conceals mail/**.
|
|
visible := func(path []string) bool {
|
|
return !strings.HasPrefix(strings.Join(path, "/"), "mail/")
|
|
}
|
|
projected := projectSchemaCatalog(catalog, visible)
|
|
|
|
if _, ok := projected.Service("mail"); ok {
|
|
t.Fatal("mail survived as an empty schema namespace after mail/** was concealed")
|
|
}
|
|
if _, ok := projected.Service("im"); !ok {
|
|
t.Fatal("unrelated visible service im was removed")
|
|
}
|
|
if got, _ := projected.Complete(nil, "ma", nil); len(got) != 0 {
|
|
t.Errorf("root dotted completion exposed concealed mail service: %v", got)
|
|
}
|
|
if got, _ := projected.Complete(nil, "im.m", nil); !reflect.DeepEqual(got, []string{"im.messages."}) {
|
|
t.Errorf("root dotted completion lost visible im service: %v", got)
|
|
}
|
|
|
|
_, err := projected.Resolve([]string{"mail", "messages", "get"})
|
|
var resolveErr *apicatalog.ResolveError
|
|
if !errors.As(err, &resolveErr) || resolveErr.Kind != apicatalog.ErrService {
|
|
t.Fatalf("concealed mail resolve error = %T %v, want unknown service", err, err)
|
|
}
|
|
if strings.Contains(strings.Join(resolveErr.Candidates, ","), "mail") {
|
|
t.Errorf("unknown-service candidates exposed concealed mail: %v", resolveErr.Candidates)
|
|
}
|
|
}
|
|
|
|
func TestSchemaSurfaceProjectionPreservesDefaultAndDeniedVisibleCatalog(t *testing.T) {
|
|
catalog := schemaSurfaceCatalog()
|
|
allVisible := func([]string) bool { return true }
|
|
|
|
var defaultOut, projectedOut bytes.Buffer
|
|
if err := runSchemaCatalog(&defaultOut, nil, core.StrictModeOff, catalog, nil); err != nil {
|
|
t.Fatalf("default schema failed: %v", err)
|
|
}
|
|
if err := runSchemaCatalog(&projectedOut, nil, core.StrictModeOff, catalog, allVisible); err != nil {
|
|
t.Fatalf("all-visible schema failed: %v", err)
|
|
}
|
|
if defaultOut.String() != projectedOut.String() {
|
|
t.Errorf("all-referenceable surface changed default schema output\ndefault: %s\nprojected: %s", defaultOut.String(), projectedOut.String())
|
|
}
|
|
}
|
|
|
|
func schemaSurfaceCatalog() apicatalog.Catalog {
|
|
service := func(name string, methods map[string]interface{}) meta.Service {
|
|
resourceName := "messages"
|
|
if name == "mail" {
|
|
resourceName = "user_mailbox.messages"
|
|
}
|
|
return meta.ServiceFromMap(map[string]interface{}{
|
|
"name": name,
|
|
"version": "v1",
|
|
"servicePath": "/open-apis/" + name + "/v1",
|
|
"resources": map[string]interface{}{
|
|
resourceName: map[string]interface{}{
|
|
"methods": methods,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
method := func(id, description string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"id": id,
|
|
"path": "/open-apis/fixture/v1/messages",
|
|
"httpMethod": "GET",
|
|
"description": description,
|
|
"risk": "read",
|
|
"accessTokens": []interface{}{"tenant"},
|
|
}
|
|
}
|
|
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
|
|
service("mail", map[string]interface{}{
|
|
"get": method("mail.user_mailbox.messages.get", "visible mail method"),
|
|
"list": method("mail.user_mailbox.messages.list", "concealable mail method"),
|
|
}),
|
|
service("im", map[string]interface{}{
|
|
"list": method("im.messages.list", "visible im method"),
|
|
}),
|
|
})
|
|
}
|