diff --git a/cmd/fetch_mcp_metadata/main.go b/cmd/fetch_mcp_metadata/main.go index 3fe47fca..a9d30003 100644 --- a/cmd/fetch_mcp_metadata/main.go +++ b/cmd/fetch_mcp_metadata/main.go @@ -17,6 +17,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "net/http" "os" "strings" @@ -28,32 +29,53 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" ) +// toolLister is the tools/list capability consumed by run; production code +// uses transport.Client, tests inject fakes. +type toolLister interface { + ListTools(ctx context.Context, endpoint string) (transport.ToolsListResult, error) +} + +// Injection points so run() is fully testable without network/keychain/exit. +var ( + osExit = os.Exit + getenv = os.Getenv + loadTokenData = auth.LoadTokenDataKeychain + staticServers = syncdata.StaticServers + registrySource = cli.EmbeddedCommandRegistryMergedJSON + listToolsTimeout = 30 * time.Second + gitHeadPath = ".git/HEAD" + newToolLister = func(token string) toolLister { + return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil) + } +) + func main() { - output := flag.String("output", "internal/cli/schema_mcp_metadata.json", "output file path") - flag.Parse() + osExit(run(os.Args[1:], os.Stderr)) +} - token := strings.TrimSpace(os.Getenv("DWS_ACCESS_TOKEN")) - if token == "" { - td, err := auth.LoadTokenDataKeychain() - if err == nil && td != nil && td.AccessToken != "" { - token = td.AccessToken - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(token)) - } - } - if token == "" { - fmt.Fprintln(os.Stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.") - os.Exit(1) +func run(args []string, stderr io.Writer) int { + flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError) + flags.SetOutput(stderr) + output := flags.String("output", "internal/cli/schema_mcp_metadata.json", "output file path") + if err := flags.Parse(args); err != nil { + return 2 } - client := transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil) + token := resolveToken(stderr) + if token == "" { + fmt.Fprintln(stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.") + return 1 + } + + client := newToolLister(token) // Iterate ALL static server endpoints (26 servers covering all products). - servers := syncdata.StaticServers() - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers)) + servers := staticServers() + fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers)) // Load CLI registry to build tool_name → interface_ref mapping. - registryMap := loadRegistryInterfaceRefs() - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap)) + registryMap := loadRegistryInterfaceRefs(stderr) + fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap)) // Load the previous schema_mcp_metadata.json to preserve hand-curated // cross-server interface_ref mappings that automated matching can't derive. @@ -82,15 +104,15 @@ func main() { if endpoint == "" { continue } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), listToolsTimeout) result, err := client.ListTools(ctx, endpoint) cancel() if err != nil { - fmt.Fprintf(os.Stderr, " [skip] %s: %v\n", srv.ID, err) + fmt.Fprintf(stderr, " [skip] %s: %v\n", srv.ID, err) failedServices = append(failedServices, srv.ID) continue } - fmt.Fprintf(os.Stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools)) + fmt.Fprintf(stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools)) totalRaw += len(result.Tools) for _, tool := range result.Tools { name := strings.TrimSpace(tool.Name) @@ -113,7 +135,7 @@ func main() { matched++ } } - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched) + fmt.Fprintf(stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched) // Fill gaps: for registry canonicals not covered by MCP tools/list OR // previous data, add stub entries (interface_ref only). @@ -128,13 +150,13 @@ func main() { stubs++ } if stubs > 0 { - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs) + fmt.Fprintf(stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs) } // Compute coverage fields required by check-schema-catalog.sh. Failed // services must be reported honestly so policy can spot snapshot gaps. if len(failedServices) > 0 { - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n", + fmt.Fprintf(stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n", len(failedServices), len(servers), strings.Join(failedServices, ", ")) } @@ -146,22 +168,44 @@ func main() { } // source_revision: git commit hash (proves provenance). - if rev, err := os.ReadFile(".git/HEAD"); err == nil { + if rev, err := os.ReadFile(gitHeadPath); err == nil { metadata["source_revision"] = strings.TrimSpace(string(rev)) } + if err := writeMetadata(*output, metadata); err != nil { + fmt.Fprintf(stderr, "fetch_mcp_metadata: %v\n", err) + return 1 + } + fmt.Fprintf(stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output) + return 0 +} + +// resolveToken returns the access token from DWS_ACCESS_TOKEN or, as a +// fallback, the DWS keychain. +func resolveToken(stderr io.Writer) string { + token := strings.TrimSpace(getenv("DWS_ACCESS_TOKEN")) + if token != "" { + return token + } + td, err := loadTokenData() + if err != nil || td == nil || td.AccessToken == "" { + return "" + } + fmt.Fprintf(stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(td.AccessToken)) + return td.AccessToken +} + +// writeMetadata marshals the snapshot and writes it to the output path. +func writeMetadata(path string, metadata map[string]any) error { data, err := json.MarshalIndent(metadata, "", " ") if err != nil { - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: marshal failed: %v\n", err) - os.Exit(1) + return fmt.Errorf("marshal failed: %w", err) } data = append(data, '\n') - - if err := os.WriteFile(*output, data, 0644); err != nil { - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: write %s failed: %v\n", *output, err) - os.Exit(1) + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("write %s failed: %w", path, err) } - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output) + return nil } // buildCoverage reports snapshot coverage honestly: snapshot_services only @@ -211,10 +255,10 @@ func mergeLiveMCPTool(allTools map[string]map[string]any, canonicalKey string, t // loadRegistryInterfaceRefs loads the reviewed split CommandRegistry through // the cli package's reassembly API and builds a canonical_path → // {product_id, rpc_name} mapping for interface_ref injection. -func loadRegistryInterfaceRefs() map[string]map[string]string { - data, err := cli.EmbeddedCommandRegistryMergedJSON() +func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string { + data, err := registrySource() if err != nil { - fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err) + fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot load registry: %v\n", err) return map[string]map[string]string{} } var reg struct { diff --git a/cmd/fetch_mcp_metadata/main_test.go b/cmd/fetch_mcp_metadata/main_test.go index e127b75d..2239bf2a 100644 --- a/cmd/fetch_mcp_metadata/main_test.go +++ b/cmd/fetch_mcp_metadata/main_test.go @@ -4,14 +4,25 @@ package main import ( + "bytes" + "context" + "encoding/json" + "errors" + "math" + "os" + "path/filepath" "reflect" + "strings" "testing" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport" ) func TestLoadRegistryInterfaceRefsUsesSplitRegistry(t *testing.T) { - refs := loadRegistryInterfaceRefs() + var stderr bytes.Buffer + refs := loadRegistryInterfaceRefs(&stderr) if len(refs) == 0 { t.Fatal("loadRegistryInterfaceRefs() returned no reviewed commands") } @@ -107,3 +118,271 @@ func TestBuildCoverageFullSnapshotHasNoMissingServices(t *testing.T) { t.Fatalf("missing_services = %#v, want empty non-nil slice", got["missing_services"]) } } + +// fakeLister returns canned tools/list results per endpoint. +type fakeLister struct { + results map[string]transport.ToolsListResult + errs map[string]error +} + +func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.ToolsListResult, error) { + if err := f.errs[endpoint]; err != nil { + return transport.ToolsListResult{}, err + } + return f.results[endpoint], nil +} + +// stubDeps swaps every injection point for the duration of one test. +func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() ([]byte, error)) { + t.Helper() + origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource + t.Cleanup(func() { + getenv, loadTokenData, staticServers, newToolLister, registrySource = origGetenv, origLoad, origServers, origNew, origRegistry + }) + getenv = func(key string) string { + if key == "DWS_ACCESS_TOKEN" { + return token + } + return "" + } + loadTokenData = keychain + staticServers = func() []syncdata.ServerInfo { return servers } + newToolLister = func(string) toolLister { return lister } + registrySource = registry +} + +func testRegistryJSON() ([]byte, error) { + return []byte(`{"version":1,"products":[{"id":"doc","tools":[{"canonical_path":"doc.copy_document"},{"canonical_path":"doc.get_document"},{"canonical_path":"bad-entry"}]}]}`), nil +} + +func TestRunNoTokenFails(t *testing.T) { + stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryJSON) + var stderr bytes.Buffer + if code := run(nil, &stderr); code != 1 { + t.Fatalf("run() = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "no auth token") { + t.Fatalf("stderr = %q, want no-auth-token hint", stderr.String()) + } +} + +func TestRunInvalidFlagFails(t *testing.T) { + stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryJSON) + var stderr bytes.Buffer + if code := run([]string{"--nonexistent"}, &stderr); code != 2 { + t.Fatalf("run() = %d, want 2", code) + } +} + +func TestResolveTokenKeychainFallback(t *testing.T) { + stubDeps(t, "", func() (*auth.TokenData, error) { + return &auth.TokenData{AccessToken: "kc-token"}, nil + }, nil, &fakeLister{}, testRegistryJSON) + var stderr bytes.Buffer + if got := resolveToken(&stderr); got != "kc-token" { + t.Fatalf("resolveToken() = %q, want kc-token", got) + } + if !strings.Contains(stderr.String(), "loaded token from keychain") { + t.Fatalf("stderr = %q, want keychain log", stderr.String()) + } +} + +func TestResolveTokenEmptyKeychainToken(t *testing.T) { + stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryJSON) + var stderr bytes.Buffer + if got := resolveToken(&stderr); got != "" { + t.Fatalf("resolveToken() = %q, want empty", got) + } +} + +func TestRunWritesSnapshotWithHonestCoverage(t *testing.T) { + servers := []syncdata.ServerInfo{ + {ID: "doc", Endpoint: "https://doc.example"}, + {ID: "sheet", Endpoint: "https://sheet.example"}, + {ID: "blank", Endpoint: " "}, + } + lister := &fakeLister{ + results: map[string]transport.ToolsListResult{ + "https://doc.example": {Tools: []transport.ToolDescriptor{ + {Name: "copy_document", Title: "复制文档", Description: "copy", InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "doc_id": map[string]any{"type": "string", "description": "文档 ID", "default": "d", "enum": []any{"a", "b", 3}}, + "bogus": "not-a-map", + }, + "required": []any{"doc_id", 42}, + }}, + {Name: " "}, + {Name: "not_in_registry"}, + }}, + }, + errs: map[string]error{"https://sheet.example": errors.New("boom")}, + } + stubDeps(t, "env-token", nil, servers, lister, testRegistryJSON) + + dir := t.TempDir() + output := filepath.Join(dir, "snapshot.json") + prev := `{"tools":{"doc.get_document":{"interface_ref":{"product_id":"doc-helper","rpc_name":"fetch_document"}}}}` + if err := os.WriteFile(output, []byte(prev), 0o600); err != nil { + t.Fatal(err) + } + + var stderr bytes.Buffer + if code := run([]string{"--output", output}, &stderr); code != 0 { + t.Fatalf("run() = %d, stderr=%s", code, stderr.String()) + } + + data, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + var snapshot struct { + Version int `json:"version"` + Coverage map[string]any `json:"coverage"` + Tools map[string]map[string]any + } + if err := json.Unmarshal(data, &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.Version != 1 { + t.Fatalf("version = %d", snapshot.Version) + } + if got := snapshot.Coverage["snapshot_services"].(float64); got != 2 { + t.Fatalf("snapshot_services = %v, want 2 (3 servers - 1 failed; blank endpoint not counted as failed)", got) + } + if got := snapshot.Coverage["missing_services"].([]any); len(got) != 1 || got[0] != "sheet" { + t.Fatalf("missing_services = %v, want [sheet]", got) + } + live := snapshot.Tools["doc.copy_document"] + if live == nil || live["title"] != "复制文档" { + t.Fatalf("doc.copy_document = %#v, want live metadata", live) + } + params := live["parameters"].(map[string]any) + docID := params["doc_id"].(map[string]any) + if docID["type"] != "string" || docID["required"] != true || docID["default"] != "d" { + t.Fatalf("doc_id = %#v", docID) + } + if enum := docID["enum"].([]any); len(enum) != 2 { + t.Fatalf("enum = %v, want the 2 string members only", enum) + } + if _, ok := params["bogus"]; ok { + t.Fatal("non-map property should be skipped") + } + prevRef := snapshot.Tools["doc.get_document"]["interface_ref"].(map[string]any) + if prevRef["product_id"] != "doc-helper" { + t.Fatalf("previous reviewed ref lost: %#v", prevRef) + } + if _, ok := snapshot.Tools["not_in_registry"]; ok { + t.Fatal("tools outside the registry must be dropped") + } + if !strings.Contains(stderr.String(), "services unreachable: sheet") { + t.Fatalf("stderr = %q, want unreachable log", stderr.String()) + } +} + +func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) { + stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON) + output := filepath.Join(t.TempDir(), "snapshot.json") + if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil { + t.Fatal(err) + } + var stderr bytes.Buffer + if code := run([]string{"--output", output}, &stderr); code != 0 { + t.Fatalf("run() = %d, stderr=%s", code, stderr.String()) + } +} + +func TestRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) { + stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return nil, errors.New("no registry") }) + output := filepath.Join(t.TempDir(), "snapshot.json") + var stderr bytes.Buffer + if code := run([]string{"--output", output}, &stderr); code != 0 { + t.Fatalf("run() = %d, stderr=%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "cannot load registry") { + t.Fatalf("stderr = %q, want registry warning", stderr.String()) + } +} + +func TestRunUnparsableRegistryYieldsNoRefs(t *testing.T) { + var stderr bytes.Buffer + stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() ([]byte, error) { return []byte("{bad"), nil }) + if refs := loadRegistryInterfaceRefs(&stderr); len(refs) != 0 { + t.Fatalf("refs = %v, want empty for unparsable registry", refs) + } +} + +func TestRunWriteFailure(t *testing.T) { + stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON) + var stderr bytes.Buffer + badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json") + if code := run([]string{"--output", badPath}, &stderr); code != 1 { + t.Fatalf("run() = %d, want 1 on write failure", code) + } +} + +func TestWriteMetadataMarshalFailure(t *testing.T) { + err := writeMetadata(filepath.Join(t.TempDir(), "out.json"), map[string]any{"bad": math.NaN()}) + if err == nil || !strings.Contains(err.Error(), "marshal failed") { + t.Fatalf("err = %v, want marshal failure", err) + } +} + +func TestMainDelegatesToRun(t *testing.T) { + stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON) + origExit, origArgs := osExit, os.Args + t.Cleanup(func() { osExit, os.Args = origExit, origArgs }) + exitCode := -1 + osExit = func(code int) { exitCode = code } + os.Args = []string{"fetch_mcp_metadata", "--output", filepath.Join(t.TempDir(), "snapshot.json")} + main() + if exitCode != 0 { + t.Fatalf("main() exited with %d, want 0", exitCode) + } +} + +func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) { + if got := extractParams(nil); got != nil { + t.Fatalf("extractParams(nil) = %v, want nil", got) + } + if got := extractParams(map[string]any{"type": "object"}); got != nil { + t.Fatalf("extractParams(no properties) = %v, want nil", got) + } +} + +func TestNewToolListerBuildsAuthedClient(t *testing.T) { + if lister := newToolLister("tok"); lister == nil { + t.Fatal("newToolLister returned nil") + } +} + +func TestRunRecordsSourceRevision(t *testing.T) { + stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryJSON) + dir := t.TempDir() + head := filepath.Join(dir, "HEAD") + if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil { + t.Fatal(err) + } + origHead := gitHeadPath + t.Cleanup(func() { gitHeadPath = origHead }) + gitHeadPath = head + + output := filepath.Join(dir, "snapshot.json") + var stderr bytes.Buffer + if code := run([]string{"--output", output}, &stderr); code != 0 { + t.Fatalf("run() = %d, stderr=%s", code, stderr.String()) + } + data, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + var snapshot struct { + SourceRevision string `json:"source_revision"` + } + if err := json.Unmarshal(data, &snapshot); err != nil { + t.Fatal(err) + } + if snapshot.SourceRevision != "ref: refs/heads/feature" { + t.Fatalf("source_revision = %q", snapshot.SourceRevision) + } +} diff --git a/internal/cli/command_meta.go b/internal/cli/command_meta.go index b9b4e726..88615d7a 100644 --- a/internal/cli/command_meta.go +++ b/internal/cli/command_meta.go @@ -60,10 +60,15 @@ var ( // initMetaByCLIPath builds the cli_path → CommandMeta lookup from the embedded // catalog. Runs once (sync.Once); the catalog is already decoded at package init. func initMetaByCLIPath() { - metaByCLIPath = make(map[string]CommandMeta) - loaded := embeddedSchemaCatalog() + metaByCLIPath = buildMetaByCLIPath(embeddedSchemaCatalog()) +} + +// buildMetaByCLIPath constructs the lookup from a loaded catalog snapshot. +// Split from initMetaByCLIPath so malformed-snapshot guards stay testable. +func buildMetaByCLIPath(loaded loadedSchemaCatalog) map[string]CommandMeta { + lookup := make(map[string]CommandMeta) if loaded.Snapshot.Tools == nil { - return + return lookup } for _, tool := range loaded.Snapshot.Tools { cliPath := catalogStringVal(tool, "cli_path") @@ -91,7 +96,7 @@ func initMetaByCLIPath() { Examples: catalogStringSliceVal(tool, "examples"), }, } - metaByCLIPath[cliPath] = meta + lookup[cliPath] = meta // Register each compat alias path (e.g. "report list") against the same // metadata. Primary cli_path wins on collision so an alias can never // shadow another command's canonical entry. @@ -100,11 +105,12 @@ func initMetaByCLIPath() { if alias == "" || alias == cliPath { continue } - if _, exists := metaByCLIPath[alias]; !exists { - metaByCLIPath[alias] = meta + if _, exists := lookup[alias]; !exists { + lookup[alias] = meta } } } + return lookup } // catalogStringSliceVal reads a []string field from a catalog tool map. diff --git a/internal/cli/command_safety.go b/internal/cli/command_safety.go index 4dbf987c..856da507 100644 --- a/internal/cli/command_safety.go +++ b/internal/cli/command_safety.go @@ -16,7 +16,6 @@ package cli import ( "fmt" "strings" - "sync" "github.com/spf13/cobra" ) @@ -41,34 +40,6 @@ func (s CommandSafety) ShouldRender() bool { (s.Risk != "" && s.Risk != "low") } -var ( - safetyByCLIPathOnce sync.Once - safetyByCLIPath map[string]CommandSafety -) - -// initSafetyByCLIPath builds a cli_path → CommandSafety lookup from the -// embedded catalog. Runs once (sync.Once); the catalog is already decoded at -// package init, so this is a cheap map iteration. -func initSafetyByCLIPath() { - safetyByCLIPath = make(map[string]CommandSafety) - loaded := embeddedSchemaCatalog() - if loaded.Snapshot.Tools == nil { - return - } - for _, tool := range loaded.Snapshot.Tools { - cliPath := catalogStringVal(tool, "cli_path") - if cliPath == "" { - continue - } - safetyByCLIPath[cliPath] = CommandSafety{ - Effect: catalogStringVal(tool, "effect"), - Risk: catalogStringVal(tool, "risk"), - Confirmation: catalogStringVal(tool, "confirmation"), - Idempotency: catalogStringVal(tool, "idempotency"), - } - } -} - // catalogStringVal reads a string field from a catalog tool map[string]any. func catalogStringVal(tool map[string]any, key string) string { if v, ok := tool[key].(string); ok { diff --git a/internal/cli/schema_catalog.go b/internal/cli/schema_catalog.go index 47037f50..0390f3f0 100644 --- a/internal/cli/schema_catalog.go +++ b/internal/cli/schema_catalog.go @@ -109,39 +109,50 @@ type schemaCatalogToolShard struct { // whole payload: if any shard is missing, stale, or tampered, the content hash // check in loadSchemaCatalogSnapshot fails exactly as before. func assembleEmbeddedSchemaCatalog() (loadedSchemaCatalog, error) { - var envelope schemaCatalogEnvelope - if err := decodeStrictSchemaJSON(embeddedSchemaCatalogEnvelopeJSON, &envelope); err != nil { - return loadedSchemaCatalog{}, fmt.Errorf("decode embedded schema catalog.json: %w", err) - } - entries, err := embeddedSchemaCatalogTools.ReadDir("schema_catalog/tools") + snapshot, err := assembleSchemaCatalogSnapshot(embeddedSchemaCatalogEnvelopeJSON, embeddedSchemaCatalogTools, "schema_catalog/tools") if err != nil { - return loadedSchemaCatalog{}, fmt.Errorf("read embedded schema catalog tools directory: %w", err) + return loadedSchemaCatalog{}, err + } + return loadSchemaCatalogSnapshot(snapshot) +} + +// assembleSchemaCatalogSnapshot merges an envelope document and a directory of +// per-product tool shards back into the single-document snapshot shape. Split +// from assembleEmbeddedSchemaCatalog so shard failure modes stay testable +// against fake filesystems. +func assembleSchemaCatalogSnapshot(envelopeJSON []byte, shards fs.FS, dir string) (SchemaCatalogSnapshot, error) { + var envelope schemaCatalogEnvelope + if err := decodeStrictSchemaJSON(envelopeJSON, &envelope); err != nil { + return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog.json: %w", err) + } + entries, err := fs.ReadDir(shards, dir) + if err != nil { + return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog tools directory: %w", err) } tools := make(map[string]map[string]any, len(entries)*8) for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } - data, readErr := fs.ReadFile(embeddedSchemaCatalogTools, "schema_catalog/tools/"+entry.Name()) + data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name()) if readErr != nil { - return loadedSchemaCatalog{}, fmt.Errorf("read embedded schema catalog shard %s: %w", entry.Name(), readErr) + return SchemaCatalogSnapshot{}, fmt.Errorf("read embedded schema catalog shard %s: %w", entry.Name(), readErr) } var shard schemaCatalogToolShard if err := decodeStrictSchemaJSON(data, &shard); err != nil { - return loadedSchemaCatalog{}, fmt.Errorf("decode embedded schema catalog shard %s: %w", entry.Name(), err) + return SchemaCatalogSnapshot{}, fmt.Errorf("decode embedded schema catalog shard %s: %w", entry.Name(), err) } for canonical, spec := range shard.Tools { tools[canonical] = spec } } - snapshot := SchemaCatalogSnapshot{ + return SchemaCatalogSnapshot{ Version: envelope.Version, SurfaceHash: envelope.SurfaceHash, SourceHash: envelope.SourceHash, Catalog: envelope.Catalog, Tools: tools, - } - return loadSchemaCatalogSnapshot(snapshot) + }, nil } func embeddedSchemaCatalogError() error { diff --git a/internal/cli/schema_catalog_structure_test.go b/internal/cli/schema_catalog_structure_test.go index f09bb400..827f113e 100644 --- a/internal/cli/schema_catalog_structure_test.go +++ b/internal/cli/schema_catalog_structure_test.go @@ -15,6 +15,7 @@ package cli import ( "encoding/json" + "fmt" "strings" "testing" ) @@ -120,6 +121,12 @@ func TestValidateCatalogStructureRejectsViolations(t *testing.T) { mutate: func(e map[string]any) { e["effect"] = "mutate" }, want: `"effect" = "mutate"`, }, + { + // 非字符串枚举字段:类型错误由字符串校验报告,枚举循环跳过。 + name: "non-string enum field skips enum check", + mutate: func(e map[string]any) { e["effect"] = float64(1) }, + want: `"effect" must be a non-empty string`, + }, { name: "mcp without interface_ref", mutate: func(e map[string]any) { delete(e, "interface_ref") }, @@ -193,6 +200,91 @@ func TestValidateCatalogStructureRejectsViolations(t *testing.T) { mutate: func(e map[string]any) { e["examples"] = []any{"ok", float64(1)} }, want: `"examples" must be an array of strings`, }, + { + name: "use_when must be an array at all", + mutate: func(e map[string]any) { e["use_when"] = "单个字符串" }, + want: `"use_when" must be an array of strings`, + }, + { + name: "field_provenance must be object", + mutate: func(e map[string]any) { e["field_provenance"] = "not-an-object" }, + want: `"field_provenance" must be an object`, + }, + { + name: "parameters must be object", + mutate: func(e map[string]any) { e["parameters"] = []any{"x"} }, + want: `"parameters" must be an object`, + }, + { + name: "parameter_count must be number", + mutate: func(e map[string]any) { e["parameter_count"] = "1" }, + want: `"parameter_count" must be a number`, + }, + { + name: "parameter entry must be object", + mutate: func(e map[string]any) { + e["parameters"] = map[string]any{"base-id": "not-an-object"} + e["parameter_count"] = float64(1) + }, + want: `parameter "base-id" must be an object`, + }, + { + name: "interface_ref must be object", + mutate: func(e map[string]any) { e["interface_ref"] = "aitable.create_records" }, + want: "interface_ref must be an object", + }, + { + name: "interface_ref keys must be non-empty", + mutate: func(e map[string]any) { + e["interface_ref"] = map[string]any{"product_id": " ", "rpc_name": float64(1)} + }, + want: "interface_ref.product_id must be a non-empty string", + }, + { + name: "mcp must not set interface_reason", + mutate: func(e map[string]any) { e["interface_reason"] = "多余理由" }, + want: "interface_mode=mcp must not set interface_reason", + }, + { + name: "param description must be non-empty", + mutate: func(e map[string]any) { + params := e["parameters"].(map[string]any) + params["base-id"].(map[string]any)["description"] = " " + }, + want: `parameter "base-id": description must be a non-empty string`, + }, + { + name: "param field_provenance must be object", + mutate: func(e map[string]any) { + params := e["parameters"].(map[string]any) + params["base-id"].(map[string]any)["field_provenance"] = "x" + }, + want: `parameter "base-id": field_provenance must be an object`, + }, + { + name: "param cli_required must be bool", + mutate: func(e map[string]any) { + params := e["parameters"].(map[string]any) + params["base-id"].(map[string]any)["cli_required"] = "yes" + }, + want: `parameter "base-id": cli_required must be a boolean`, + }, + { + name: "param enum must be array", + mutate: func(e map[string]any) { + params := e["parameters"].(map[string]any) + params["base-id"].(map[string]any)["enum"] = "A,B" + }, + want: `parameter "base-id": enum must be an array`, + }, + { + name: "param enum items must be strings", + mutate: func(e map[string]any) { + params := e["parameters"].(map[string]any) + params["base-id"].(map[string]any)["enum"] = []any{"A", float64(2)} + }, + want: `parameter "base-id": enum[1] must be a string`, + }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -220,3 +312,47 @@ func TestValidateCatalogStructureRejectsBadEnvelope(t *testing.T) { t.Fatal("ValidateCatalogStructure() = nil, want decode error") } } + +func TestValidateCatalogStructureSortsViolationsAcrossTools(t *testing.T) { + // 两个工具各制造两条违规,驱动排序比较器的 tool 与 message 两个分支。 + bad1 := validCatalogToolEntry() + bad1["effect"] = "mutate" + bad1["risk"] = "extreme" + bad2 := validCatalogToolEntry() + bad2["canonical_path"] = "zz.last_tool" + bad2["effect"] = "mutate" + bad2["risk"] = "extreme" + payload, err := json.Marshal(map[string]any{ + "version": SchemaCatalogSnapshotVersion, + "tools": map[string]any{"zz.last_tool": bad2, "aitable.record_create": bad1}, + }) + if err != nil { + t.Fatal(err) + } + verr := ValidateCatalogStructure(payload) + if verr == nil { + t.Fatal("ValidateCatalogStructure() = nil, want sorted violations") + } + msg := verr.Error() + if !strings.Contains(msg, "(4 total)") { + t.Fatalf("error = %v, want 4 total violations", verr) + } + if first, second := strings.Index(msg, "aitable.record_create"), strings.Index(msg, "zz.last_tool"); first < 0 || second < 0 || first > second { + t.Fatalf("violations not sorted by tool: %v", verr) + } +} + +func TestValidateCatalogStructureTruncatesViolationList(t *testing.T) { + // 单工具制造超过 25 条违规,验证截断提示。 + entry := validCatalogToolEntry() + for i := 0; i < 30; i++ { + entry[fmt.Sprintf("unknown_field_%02d", i)] = true + } + err := ValidateCatalogStructure(catalogPayload(t, entry)) + if err == nil { + t.Fatal("ValidateCatalogStructure() = nil, want truncated violation list") + } + if !strings.Contains(err.Error(), "more violations") { + t.Fatalf("error = %v, want truncation marker", err) + } +} diff --git a/internal/cli/schema_command_registry.go b/internal/cli/schema_command_registry.go index c49f6afb..7419d76c 100644 --- a/internal/cli/schema_command_registry.go +++ b/internal/cli/schema_command_registry.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "regexp" "sort" "strings" @@ -108,15 +109,22 @@ func loadEmbeddedCommandRegistry() (CommandRegistry, error) { // and reassembles them into a single CommandRegistry, identical to the previous // single-file layout. Mirrors assembleEmbeddedSchemaCatalog for catalog shards. func assembleCommandRegistry() (CommandRegistry, error) { + return assembleCommandRegistryFrom(embeddedSchemaCommandRegistryEnvelopeJSON, embeddedSchemaCommandRegistryProducts, "schema_command_registry/products") +} + +// assembleCommandRegistryFrom reassembles an envelope plus product shard +// directory. Split from assembleCommandRegistry so shard failure modes stay +// testable against fake filesystems. +func assembleCommandRegistryFrom(envelopeJSON []byte, shards fs.FS, dir string) (CommandRegistry, error) { var envelope struct { Schema string `json:"$schema,omitempty"` Version int `json:"version"` } - if err := json.Unmarshal(embeddedSchemaCommandRegistryEnvelopeJSON, &envelope); err != nil { + if err := json.Unmarshal(envelopeJSON, &envelope); err != nil { return CommandRegistry{}, fmt.Errorf("decode embedded command registry envelope: %w", err) } - entries, err := embeddedSchemaCommandRegistryProducts.ReadDir("schema_command_registry/products") + entries, err := fs.ReadDir(shards, dir) if err != nil { return CommandRegistry{}, fmt.Errorf("read embedded command registry products: %w", err) } @@ -128,7 +136,7 @@ func assembleCommandRegistry() (CommandRegistry, error) { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } - data, readErr := embeddedSchemaCommandRegistryProducts.ReadFile("schema_command_registry/products/" + entry.Name()) + data, readErr := fs.ReadFile(shards, dir+"/"+entry.Name()) if readErr != nil { return CommandRegistry{}, fmt.Errorf("read embedded command registry shard %s: %w", entry.Name(), readErr) } @@ -141,10 +149,8 @@ func assembleCommandRegistry() (CommandRegistry, error) { // Encode to bytes and decode via the existing pipeline so all validation // (canonical patterns, duplicate detection, visibility) runs identically. - data, err := json.Marshal(snapshot) - if err != nil { - return CommandRegistry{}, fmt.Errorf("marshal reassembled command registry: %w", err) - } + // Marshal of this plain struct cannot fail. + data, _ := json.Marshal(snapshot) return decodeCommandRegistry(data) } @@ -152,14 +158,20 @@ func assembleCommandRegistry() (CommandRegistry, error) { // into a single JSON document matching the pre-split layout. Used by tests that // need the full registry bytes. func EmbeddedCommandRegistryMergedJSON() ([]byte, error) { + return mergedCommandRegistryJSON(embeddedSchemaCommandRegistryEnvelopeJSON, embeddedSchemaCommandRegistryProducts, "schema_command_registry/products") +} + +// mergedCommandRegistryJSON is the injectable core of +// EmbeddedCommandRegistryMergedJSON. +func mergedCommandRegistryJSON(envelopeJSON []byte, shards fs.FS, dir string) ([]byte, error) { var envelope struct { Schema string `json:"$schema,omitempty"` Version int `json:"version"` } - if err := json.Unmarshal(embeddedSchemaCommandRegistryEnvelopeJSON, &envelope); err != nil { + if err := json.Unmarshal(envelopeJSON, &envelope); err != nil { return nil, err } - entries, err := embeddedSchemaCommandRegistryProducts.ReadDir("schema_command_registry/products") + entries, err := fs.ReadDir(shards, dir) if err != nil { return nil, err } @@ -168,7 +180,7 @@ func EmbeddedCommandRegistryMergedJSON() ([]byte, error) { if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { continue } - data, err := embeddedSchemaCommandRegistryProducts.ReadFile("schema_command_registry/products/" + entry.Name()) + data, err := fs.ReadFile(shards, dir+"/"+entry.Name()) if err != nil { return nil, err } diff --git a/internal/cli/schema_shards_coverage_test.go b/internal/cli/schema_shards_coverage_test.go new file mode 100644 index 00000000..c2844b2b --- /dev/null +++ b/internal/cli/schema_shards_coverage_test.go @@ -0,0 +1,233 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cli + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + "testing/fstest" + + "github.com/spf13/cobra" +) + +// failingReadFS lists entries but fails ReadFile, driving shard-read errors. +type failingReadFS struct{ fstest.MapFS } + +func (f failingReadFS) ReadFile(name string) ([]byte, error) { + if strings.HasSuffix(name, ".json") { + return nil, errors.New("shard read denied") + } + return f.MapFS.ReadFile(name) +} + +func TestAssembleSchemaCatalogSnapshotMergesShards(t *testing.T) { + envelope := []byte(`{"version":1,"surface_hash":"sha256:s","source_hash":"sha256:c","catalog":{"kind":"schema"}}`) + shards := fstest.MapFS{ + "tools/doc.json": {Data: []byte(`{"product":"doc","tools":{"doc.copy":{"title":"复制"}}}`)}, + "tools/sheet.json": {Data: []byte(`{"product":"sheet","tools":{"sheet.get":{"title":"读取"}}}`)}, + "tools/README.md": {Data: []byte("not a shard")}, + } + snapshot, err := assembleSchemaCatalogSnapshot(envelope, shards, "tools") + if err != nil { + t.Fatalf("assembleSchemaCatalogSnapshot() error = %v", err) + } + if snapshot.Version != 1 || snapshot.SurfaceHash != "sha256:s" || snapshot.SourceHash != "sha256:c" { + t.Fatalf("envelope fields lost: %+v", snapshot) + } + if len(snapshot.Tools) != 2 || snapshot.Tools["doc.copy"] == nil || snapshot.Tools["sheet.get"] == nil { + t.Fatalf("tools = %#v, want merged doc.copy + sheet.get", snapshot.Tools) + } +} + +func TestAssembleSchemaCatalogSnapshotFailureModes(t *testing.T) { + valid := []byte(`{"version":1,"source_hash":"sha256:c","catalog":{}}`) + if _, err := assembleSchemaCatalogSnapshot([]byte("{bad"), fstest.MapFS{}, "tools"); err == nil || !strings.Contains(err.Error(), "decode embedded schema catalog.json") { + t.Fatalf("bad envelope err = %v", err) + } + if _, err := assembleSchemaCatalogSnapshot(valid, fstest.MapFS{}, "missing-dir"); err == nil || !strings.Contains(err.Error(), "tools directory") { + t.Fatalf("missing dir err = %v", err) + } + badShard := fstest.MapFS{"tools/doc.json": {Data: []byte("{bad")}} + if _, err := assembleSchemaCatalogSnapshot(valid, badShard, "tools"); err == nil || !strings.Contains(err.Error(), "decode embedded schema catalog shard") { + t.Fatalf("bad shard err = %v", err) + } + denied := failingReadFS{fstest.MapFS{"tools/doc.json": {Data: []byte(`{}`)}}} + if _, err := assembleSchemaCatalogSnapshot(valid, denied, "tools"); err == nil || !strings.Contains(err.Error(), "read embedded schema catalog shard") { + t.Fatalf("denied shard err = %v", err) + } +} + +func TestAssembleCommandRegistryFromShards(t *testing.T) { + envelope := []byte(`{"$schema":"./schema_command_registry.schema.json","version":1}`) + shards := fstest.MapFS{ + "products/doc.json": {Data: []byte(`{"id":"doc","tools":[{"canonical_path":"doc.copy_document","cli_path":"doc copy"}]}`)}, + "products/notes.md": {Data: []byte("skip me")}, + } + registry, err := assembleCommandRegistryFrom(envelope, shards, "products") + if err != nil { + t.Fatalf("assembleCommandRegistryFrom() error = %v", err) + } + if len(registry.Commands) != 1 || registry.Commands[0].CanonicalPath != "doc.copy_document" { + t.Fatalf("registry = %+v, want single doc.copy_document command", registry.Commands) + } +} + +func TestAssembleCommandRegistryFromFailureModes(t *testing.T) { + valid := []byte(`{"version":1}`) + if _, err := assembleCommandRegistryFrom([]byte("{bad"), fstest.MapFS{}, "products"); err == nil || !strings.Contains(err.Error(), "envelope") { + t.Fatalf("bad envelope err = %v", err) + } + if _, err := assembleCommandRegistryFrom(valid, fstest.MapFS{}, "missing-dir"); err == nil || !strings.Contains(err.Error(), "read embedded command registry products") { + t.Fatalf("missing dir err = %v", err) + } + badShard := fstest.MapFS{"products/doc.json": {Data: []byte("{bad")}} + if _, err := assembleCommandRegistryFrom(valid, badShard, "products"); err == nil || !strings.Contains(err.Error(), "decode embedded command registry shard") { + t.Fatalf("bad shard err = %v", err) + } + denied := failingReadFS{fstest.MapFS{"products/doc.json": {Data: []byte(`{}`)}}} + if _, err := assembleCommandRegistryFrom(valid, denied, "products"); err == nil || !strings.Contains(err.Error(), "read embedded command registry shard") { + t.Fatalf("denied shard err = %v", err) + } +} + +func TestMergedCommandRegistryJSONFailureModes(t *testing.T) { + valid := []byte(`{"version":1}`) + if _, err := mergedCommandRegistryJSON([]byte("{bad"), fstest.MapFS{}, "products"); err == nil { + t.Fatal("bad envelope should fail") + } + if _, err := mergedCommandRegistryJSON(valid, fstest.MapFS{}, "missing-dir"); err == nil { + t.Fatal("missing dir should fail") + } + denied := failingReadFS{fstest.MapFS{"products/doc.json": {Data: []byte(`{}`)}}} + if _, err := mergedCommandRegistryJSON(valid, denied, "products"); err == nil { + t.Fatal("denied shard should fail") + } + // Invalid shard JSON survives assembly as RawMessage but fails the final + // products marshal. + badShard := fstest.MapFS{"products/doc.json": {Data: []byte("{bad")}} + if _, err := mergedCommandRegistryJSON(valid, badShard, "products"); err == nil { + t.Fatal("invalid RawMessage shard should fail marshal") + } +} + +func TestMergedCommandRegistryJSONDefaultsSchemaAndSkipsNonJSON(t *testing.T) { + shards := fstest.MapFS{ + "products/doc.json": {Data: []byte(`{"id":"doc","tools":[]}`)}, + "products/notes.md": {Data: []byte("skip me")}, + } + data, err := mergedCommandRegistryJSON([]byte(`{"version":1}`), shards, "products") + if err != nil { + t.Fatalf("mergedCommandRegistryJSON() error = %v", err) + } + var merged struct { + Schema string `json:"$schema"` + Version int `json:"version"` + Products []json.RawMessage `json:"products"` + } + if err := json.Unmarshal(data, &merged); err != nil { + t.Fatal(err) + } + if merged.Schema != "./schema_command_registry.schema.json" { + t.Fatalf("schema = %q, want defaulted schema path", merged.Schema) + } + if len(merged.Products) != 1 { + t.Fatalf("products = %d, want 1 (markdown skipped)", len(merged.Products)) + } +} + +func TestBuildMetaByCLIPathGuards(t *testing.T) { + // nil Tools:损坏快照直接返回空表。 + if got := buildMetaByCLIPath(loadedSchemaCatalog{}); len(got) != 0 { + t.Fatalf("nil tools lookup = %#v, want empty", got) + } + loaded := loadedSchemaCatalog{Snapshot: SchemaCatalogSnapshot{Tools: map[string]map[string]any{ + "doc.no_cli_path": {"canonical_path": "doc.no_cli_path"}, + "doc.primary": { + "cli_path": "doc primary", + "canonical_path": "doc.primary", + "aliases": []any{"doc alias", "doc primary", " ", float64(1)}, + }, + "doc.other": { + "cli_path": "doc alias", + "canonical_path": "doc.other", + }, + }}} + lookup := buildMetaByCLIPath(loaded) + if _, ok := lookup[""]; ok { + t.Fatal("entry without cli_path must be skipped") + } + // 别名与其他命令的主路径冲突时,主路径必须胜出。 + if got := lookup["doc alias"].Identity.Canonical; got != "doc.other" { + t.Fatalf("doc alias resolved to %q, want primary owner doc.other", got) + } + if got := lookup["doc primary"].Identity.Canonical; got != "doc.primary" { + t.Fatalf("doc primary resolved to %q", got) + } + // Identity.Aliases 保留 Catalog 中的字符串成员(非字符串被丢弃); + // 空白/自指别名仅在查找表注册时跳过。 + if aliases := lookup["doc primary"].Identity.Aliases; len(aliases) != 3 { + t.Fatalf("aliases = %v, want the 3 string members (float dropped)", aliases) + } +} + +func TestCatalogStringSliceValNonSlice(t *testing.T) { + if got := catalogStringSliceVal(map[string]any{"aliases": "not-a-slice"}, "aliases"); got != nil { + t.Fatalf("catalogStringSliceVal() = %v, want nil", got) + } +} + +func TestRenderSafetyAnnotation(t *testing.T) { + // dev app delete: destructive/high/user_required —— 必须渲染完整注解。 + root := &cobra.Command{Use: "dws"} + dev := &cobra.Command{Use: "dev"} + app := &cobra.Command{Use: "app"} + deleteCmd := &cobra.Command{Use: "delete"} + root.AddCommand(dev) + dev.AddCommand(app) + app.AddCommand(deleteCmd) + var out bytes.Buffer + deleteCmd.SetOut(&out) + RenderSafetyAnnotation(deleteCmd) + rendered := out.String() + if !strings.Contains(rendered, "Safety: effect=destructive") || !strings.Contains(rendered, "(需 --yes)") { + t.Fatalf("rendered = %q, want destructive annotation with confirmation hint", rendered) + } + if !strings.Contains(rendered, "idempotency=") { + t.Fatalf("rendered = %q, want idempotency field", rendered) + } + + // 未收录命令不输出任何内容。 + unknown := &cobra.Command{Use: "unknown-cmd"} + root.AddCommand(unknown) + var silent bytes.Buffer + unknown.SetOut(&silent) + RenderSafetyAnnotation(unknown) + if silent.Len() != 0 { + t.Fatalf("unknown command rendered %q, want empty", silent.String()) + } +} + +// TestAssembleEmbeddedSchemaCatalogPropagatesAssemblyError 通过临时替换嵌入的 +// envelope 字节,覆盖 assembleEmbeddedSchemaCatalog 的错误透传分支。 +func TestAssembleEmbeddedSchemaCatalogPropagatesAssemblyError(t *testing.T) { + orig := embeddedSchemaCatalogEnvelopeJSON + t.Cleanup(func() { embeddedSchemaCatalogEnvelopeJSON = orig }) + embeddedSchemaCatalogEnvelopeJSON = []byte("{bad") + if _, err := assembleEmbeddedSchemaCatalog(); err == nil || !strings.Contains(err.Error(), "decode embedded schema catalog.json") { + t.Fatalf("err = %v, want assembly error passthrough", err) + } +} diff --git a/internal/generator/cmd_schema_agent_metadata/registry_shards_test.go b/internal/generator/cmd_schema_agent_metadata/registry_shards_test.go new file mode 100644 index 00000000..45db4c6d --- /dev/null +++ b/internal/generator/cmd_schema_agent_metadata/registry_shards_test.go @@ -0,0 +1,116 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// TestMergeRegistryShardsFailureModes drives every error path of the split +// registry merge used by validateCommandRegistryFile. +func TestMergeRegistryShardsFailureModes(t *testing.T) { + t.Run("missing envelope", func(t *testing.T) { + if _, err := mergeRegistryShards(t.TempDir()); err == nil || !strings.Contains(err.Error(), "read registry envelope") { + t.Fatalf("err = %v, want envelope read failure", err) + } + }) + t.Run("bad envelope json", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte("{bad"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := mergeRegistryShards(dir); err == nil || !strings.Contains(err.Error(), "decode registry envelope") { + t.Fatalf("err = %v, want envelope decode failure", err) + } + }) + t.Run("missing products dir", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + if _, err := mergeRegistryShards(dir); err == nil || !strings.Contains(err.Error(), "read registry products dir") { + t.Fatalf("err = %v, want products dir failure", err) + } + }) + t.Run("unreadable shard", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + products := filepath.Join(dir, "products") + if err := os.MkdirAll(products, 0o755); err != nil { + t.Fatal(err) + } + // 断链符号链接:ReadDir 能列出,ReadFile 必失败。 + if err := os.Symlink(filepath.Join(dir, "nonexistent"), filepath.Join(products, "doc.json")); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := mergeRegistryShards(dir); err == nil || !strings.Contains(err.Error(), "read registry shard") { + t.Fatalf("err = %v, want shard read failure", err) + } + }) + t.Run("invalid raw shard fails products marshal", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(`{"version":1}`), 0o600); err != nil { + t.Fatal(err) + } + products := filepath.Join(dir, "products") + if err := os.MkdirAll(products, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(products, "doc.json"), []byte("{bad"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := mergeRegistryShards(dir); err == nil { + t.Fatal("err = nil, want RawMessage marshal failure for invalid shard JSON") + } + }) +} + +func TestMergeRegistryShardsSkipsNonShardEntries(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "registry.json"), []byte(`{"$schema":"./s.json","version":1}`), 0o600); err != nil { + t.Fatal(err) + } + products := filepath.Join(dir, "products") + if err := os.MkdirAll(filepath.Join(products, "nested.json"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(products, "README.md"), []byte("skip"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(products, "doc.json"), []byte(`{"id":"doc","tools":[]}`), 0o600); err != nil { + t.Fatal(err) + } + data, err := mergeRegistryShards(dir) + if err != nil { + t.Fatalf("mergeRegistryShards() error = %v", err) + } + merged := string(data) + if !strings.Contains(merged, `"id":"doc"`) || strings.Contains(merged, "skip") { + t.Fatalf("merged = %s, want doc shard only", merged) + } +} + +func TestValidateCommandRegistryFileShardDirectoryErrors(t *testing.T) { + // 目录形态但缺 envelope:mergeRegistryShards 的错误必须透传。 + dir := t.TempDir() + if err := validateCommandRegistryFile("", dir); err == nil || !strings.Contains(err.Error(), "read registry envelope") { + t.Fatalf("err = %v, want merge failure passthrough", err) + } + // 单文件形态但不可读(无读权限)。 + if os.Getuid() == 0 { + t.Skip("running as root; permission-based read failure not enforceable") + } + unreadable := filepath.Join(t.TempDir(), "registry.json") + if err := os.WriteFile(unreadable, []byte(`{"version":1}`), 0o000); err != nil { + t.Fatal(err) + } + if err := validateCommandRegistryFile("", unreadable); err == nil { + t.Fatal("err = nil, want read failure for unreadable registry file") + } +} diff --git a/internal/generator/cmd_schema_catalog/shards_write_test.go b/internal/generator/cmd_schema_catalog/shards_write_test.go new file mode 100644 index 00000000..492fc302 --- /dev/null +++ b/internal/generator/cmd_schema_catalog/shards_write_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "math" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" +) + +func TestWriteSchemaCatalogShardsClearFailure(t *testing.T) { + // 输出路径的父级是普通文件:RemoveAll 报非 IsNotExist 错误。 + parent := filepath.Join(t.TempDir(), "occupied") + if err := os.WriteFile(parent, []byte("file"), 0o600); err != nil { + t.Fatal(err) + } + err := writeSchemaCatalogShards(cli.SchemaCatalogSnapshot{}, filepath.Join(parent, "schema_catalog")) + if err == nil { + t.Fatal("err = nil, want clear/create failure under a plain file") + } +} + +func TestWriteSchemaCatalogShardsShardWriteFailure(t *testing.T) { + // canonical 前缀带路径分隔符时目标目录不存在,shard 写入必须失败。 + snapshot := cli.SchemaCatalogSnapshot{ + Version: 1, + Tools: map[string]map[string]any{ + "bad/prod.tool": {"title": "x"}, + }, + } + err := writeSchemaCatalogShards(snapshot, filepath.Join(t.TempDir(), "schema_catalog")) + if err == nil || !strings.Contains(err.Error(), "write schema catalog tools/") { + t.Fatalf("err = %v, want shard write failure", err) + } +} + +func TestWriteSchemaCatalogJSONEncodeFailure(t *testing.T) { + err := writeSchemaCatalogJSON(filepath.Join(t.TempDir(), "catalog.json"), map[string]any{"bad": math.NaN()}) + if err == nil || !strings.Contains(err.Error(), "encode catalog.json") { + t.Fatalf("err = %v, want encode failure", err) + } +} + +func TestWriteSchemaCatalogShardsEnvelopeWriteFailure(t *testing.T) { + // envelope 自身编码失败(Catalog 含 NaN)走 write catalog.json 错误分支。 + snapshot := cli.SchemaCatalogSnapshot{ + Version: 1, + Catalog: map[string]any{"bad": math.NaN()}, + } + err := writeSchemaCatalogShards(snapshot, filepath.Join(t.TempDir(), "schema_catalog")) + if err == nil || !strings.Contains(err.Error(), "write schema catalog.json") { + t.Fatalf("err = %v, want envelope write failure", err) + } +} diff --git a/internal/helpers/devapp_leaf_test.go b/internal/helpers/devapp_leaf_test.go index bb10fc39..0a7a8e56 100644 --- a/internal/helpers/devapp_leaf_test.go +++ b/internal/helpers/devapp_leaf_test.go @@ -524,3 +524,40 @@ func TestDevAppLeafToolArgsTable(t *testing.T) { }) } } + +// TestDevAppMemberRemoveValidateChain 覆盖 member remove Validate 钩子的逐级 +// 必填校验:unified-app-id → user-ids → member-type。 +func TestDevAppMemberRemoveValidateChain(t *testing.T) { + newCmd := func() *cobra.Command { + cmd := newDevAppMemberRemoveCommand(&fakeDevAppRunner{}) + cmd.Flags().Bool("yes", false, "") + if err := cmd.Flags().Set("yes", "true"); err != nil { + t.Fatal(err) + } + return cmd + } + + cmd := newCmd() + if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "unified-app-id") { + t.Fatalf("missing unified-app-id err = %v", err) + } + + cmd = newCmd() + if err := cmd.Flags().Set("unified-app-id", "APP-1"); err != nil { + t.Fatal(err) + } + if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "user-ids") { + t.Fatalf("missing user-ids err = %v", err) + } + + cmd = newCmd() + if err := cmd.Flags().Set("unified-app-id", "APP-1"); err != nil { + t.Fatal(err) + } + if err := cmd.Flags().Set("user-ids", "u1"); err != nil { + t.Fatal(err) + } + if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "member-type") { + t.Fatalf("missing member-type err = %v", err) + } +} diff --git a/internal/helpers/leaf_dispatch_test.go b/internal/helpers/leaf_dispatch_test.go new file mode 100644 index 00000000..21261a33 --- /dev/null +++ b/internal/helpers/leaf_dispatch_test.go @@ -0,0 +1,118 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "context" + "errors" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +// leafDispatchCaller 是 leaf 默认派发路径的 fake ToolCaller。 +type leafDispatchCaller struct { + dryRun bool + productID string + toolName string +} + +func (c *leafDispatchCaller) CallTool(_ context.Context, productID, toolName string, _ map[string]any) (*edition.ToolResult, error) { + c.productID = productID + c.toolName = toolName + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil +} + +func (c *leafDispatchCaller) Format() string { return "json" } +func (c *leafDispatchCaller) DryRun() bool { return c.dryRun } +func (c *leafDispatchCaller) Fields() string { return "" } +func (c *leafDispatchCaller) JQ() string { return "" } + +func withLeafDispatchCaller(t *testing.T, dryRun bool) *leafDispatchCaller { + t.Helper() + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + caller := &leafDispatchCaller{dryRun: dryRun} + InitDeps(caller) + deps.Out.w = io.Discard + return caller +} + +// TestLeafCommandDefaultDispatch 覆盖默认 callMCPTool 派发(无 Call/Server)。 +// DryRun caller 在路由前早返回,验证 RunE 走到默认派发语句且无错误。 +func TestLeafCommandDefaultDispatch(t *testing.T) { + withLeafDispatchCaller(t, true) + cmd := NewLeafCommand(LeafSpec{ + Use: "get", Tool: "get_thing", + Flags: []LeafFlag{{Name: "id", Usage: "ID", Bind: "id"}}, + }) + if err := cmd.Flags().Set("id", "x"); err != nil { + t.Fatal(err) + } + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("RunE() = %v, want nil via dry-run dispatch", err) + } +} + +// TestLeafCommandServerDispatch 覆盖显式 Server 路由分支。 +func TestLeafCommandServerDispatch(t *testing.T) { + caller := withLeafDispatchCaller(t, false) + cmd := NewLeafCommand(LeafSpec{ + Use: "get", Server: "doc", Tool: "get_document", + Flags: []LeafFlag{{Name: "id", Usage: "ID", Bind: "docId"}}, + }) + if err := cmd.Flags().Set("id", "d1"); err != nil { + t.Fatal(err) + } + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatalf("RunE() = %v", err) + } + if caller.productID != "doc" || caller.toolName != "get_document" { + t.Fatalf("dispatched to %s/%s, want doc/get_document", caller.productID, caller.toolName) + } +} + +// TestLeafCommandTransformErrorAborts 覆盖 RunE 中 leafArgs 错误传播。 +func TestLeafCommandTransformErrorAborts(t *testing.T) { + withLeafDispatchCaller(t, true) + cmd := NewLeafCommand(LeafSpec{ + Use: "get", Tool: "get_thing", + Flags: []LeafFlag{{ + Name: "when", Usage: "时间", Bind: "when", + Transform: func(string) (any, error) { return nil, errors.New("bad time format") }, + }}, + }) + if err := cmd.Flags().Set("when", "not-a-time"); err != nil { + t.Fatal(err) + } + if err := cmd.RunE(cmd, nil); err == nil || !strings.Contains(err.Error(), "bad time format") { + t.Fatalf("RunE() = %v, want transform error", err) + } +} + +// TestLeafValidateRequiredEnvDefaultHint 覆盖 EnvVar Required 未配置 +// RequiredHint 时的默认报错文案。 +func TestLeafValidateRequiredEnvDefaultHint(t *testing.T) { + spec := LeafSpec{ + Use: "send", Tool: "send_thing", + Flags: []LeafFlag{{Name: "token", Usage: "令牌", Required: true, EnvVar: "DWS_LEAF_TEST_HINTLESS"}}, + } + cmd := NewLeafCommand(spec) + err := leafValidateRequired(cmd, spec) + if err == nil || err.Error() != "flag --token is required" { + t.Fatalf("leafValidateRequired() = %v, want default hint", err) + } +} diff --git a/pkg/cmdutil/flags_required_test.go b/pkg/cmdutil/flags_required_test.go new file mode 100644 index 00000000..aafc2846 --- /dev/null +++ b/pkg/cmdutil/flags_required_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmdutil + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func newFlagsTestCommand() *cobra.Command { + cmd := &cobra.Command{Use: "send", Example: " dws send --content hi"} + cmd.Flags().String("content", "", "内容") + cmd.Flags().String("title", "", "标题") + return cmd +} + +func TestValidateRequiredFlagsReportsAllMissing(t *testing.T) { + cmd := newFlagsTestCommand() + err := ValidateRequiredFlags(cmd, "content", "title") + if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content, --title") { + t.Fatalf("err = %v, want both flags reported", err) + } + if !strings.Contains(err.Error(), cmd.Example) { + t.Fatalf("err = %v, want example hint", err) + } +} + +func TestValidateRequiredFlagsPassesWhenSet(t *testing.T) { + cmd := newFlagsTestCommand() + if err := cmd.Flags().Set("content", "hi"); err != nil { + t.Fatal(err) + } + if err := ValidateRequiredFlags(cmd, "content"); err != nil { + t.Fatalf("err = %v, want nil", err) + } +} + +func TestMissingRequiredFlagsErrorNilForEmpty(t *testing.T) { + if err := MissingRequiredFlagsError(newFlagsTestCommand()); err != nil { + t.Fatalf("err = %v, want nil for no missing flags", err) + } +} + +func TestMissingRequiredFlagsErrorFormatsNames(t *testing.T) { + err := MissingRequiredFlagsError(newFlagsTestCommand(), "content") + if err == nil || !strings.Contains(err.Error(), "missing required flag(s): --content") { + t.Fatalf("err = %v, want formatted missing flag", err) + } +} + +func TestValidateRequiredFlagWithAliases(t *testing.T) { + cmd := newFlagsTestCommand() + cmd.Flags().String("body", "", "别名") + err := ValidateRequiredFlagWithAliases(cmd, "content", "body") + if err == nil || !strings.Contains(err.Error(), "missing required flag: --content (or --body)") { + t.Fatalf("err = %v, want alias-aware missing error", err) + } + if err := cmd.Flags().Set("body", "hi"); err != nil { + t.Fatal(err) + } + if err := ValidateRequiredFlagWithAliases(cmd, "content", "body"); err != nil { + t.Fatalf("err = %v, want nil when alias satisfies requirement", err) + } +}