- Add testseam.Protect for save-and-restore seams with no up-front stub value (e.g. os.Args mutated by the code under test). - Migrate all 35 remaining manual prev/assign/t.Cleanup-restore trios to testseam.Swap/Protect across app, auth, cli, event, helpers, output, pipeline, and shortcut tests; restores can no longer be forgotten. - check-schema-catalog.sh: fail closed when a manual seam restore reappears in any *_test.go (internal/testseam exempt). Seam injection is now a mechanism, not a convention.
368 lines
13 KiB
Go
368 lines
13 KiB
Go
// 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.
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/userdef"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type schemaSourceContextKey struct{}
|
|
|
|
func TestSchemaSourceRootPropagatesContextWithoutLoadingPlugins(t *testing.T) {
|
|
pluginLoads := 0
|
|
testseam.Swap(t, &rootLoadPlugins, func(*cobra.Command, *pipeline.Engine, executor.Runner) []*cobra.Command {
|
|
pluginLoads++
|
|
return nil
|
|
})
|
|
wantContext := context.WithValue(context.Background(), schemaSourceContextKey{}, "schema")
|
|
root := NewSchemaSourceRootCommand(wantContext)
|
|
if root.Context() != wantContext {
|
|
t.Fatal("Schema source root did not retain the caller context")
|
|
}
|
|
if pluginLoads != 0 {
|
|
t.Fatalf("Schema source root loaded runtime plugins %d times", pluginLoads)
|
|
}
|
|
}
|
|
|
|
func TestCollectPluginServerCandidatesSortsAndSkipsInvalidStdio(t *testing.T) {
|
|
previousDescriptors := rootPluginDescriptors
|
|
previousClients := rootPluginStdioClients
|
|
previousDescriptor := rootPluginStdioDescriptor
|
|
t.Cleanup(func() {
|
|
rootPluginDescriptors = previousDescriptors
|
|
rootPluginStdioClients = previousClients
|
|
rootPluginStdioDescriptor = previousDescriptor
|
|
})
|
|
|
|
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "first"}}
|
|
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "second"}}
|
|
wantContext := &plugin.UserContext{UserID: "user", CorpID: "corp"}
|
|
client := transport.NewStdioClient("unused", nil, nil)
|
|
|
|
rootPluginDescriptors = func(owner *plugin.Plugin) []mcptypes.ServerDescriptor {
|
|
if owner == first {
|
|
return []mcptypes.ServerDescriptor{{Key: "same"}, {Key: " beta "}}
|
|
}
|
|
return []mcptypes.ServerDescriptor{{Key: "aardvark"}}
|
|
}
|
|
rootPluginStdioClients = func(owner *plugin.Plugin, gotContext *plugin.UserContext) []plugin.StdioServerClient {
|
|
if gotContext != wantContext {
|
|
t.Fatalf("stdio user context = %#v, want %#v", gotContext, wantContext)
|
|
}
|
|
if owner != first {
|
|
return nil
|
|
}
|
|
return []plugin.StdioServerClient{
|
|
{Key: "same", Client: client},
|
|
{Key: " alpha ", Client: client},
|
|
{Key: "invalid", Client: client},
|
|
}
|
|
}
|
|
rootPluginStdioDescriptor = func(_ *plugin.Plugin, stdio plugin.StdioServerClient) (mcptypes.ServerDescriptor, bool) {
|
|
if stdio.Key == "invalid" {
|
|
return mcptypes.ServerDescriptor{}, false
|
|
}
|
|
return mcptypes.ServerDescriptor{Key: stdio.Key}, true
|
|
}
|
|
|
|
candidates := collectPluginServerCandidates([]*plugin.Plugin{first, second}, wantContext)
|
|
if len(candidates) != 5 {
|
|
t.Fatalf("candidate count = %d, want 5", len(candidates))
|
|
}
|
|
gotKeys := make([]string, 0, len(candidates))
|
|
gotKinds := make([]string, 0, len(candidates))
|
|
for _, candidate := range candidates {
|
|
gotKeys = append(gotKeys, candidate.descriptor.Key)
|
|
if candidate.stdioClient == nil {
|
|
gotKinds = append(gotKinds, "http")
|
|
} else {
|
|
gotKinds = append(gotKinds, "stdio")
|
|
if candidate.stdioClient.Client != client {
|
|
t.Fatal("stdio candidate did not retain its client")
|
|
}
|
|
}
|
|
}
|
|
if want := []string{" alpha ", " beta ", "same", "same", "aardvark"}; !reflect.DeepEqual(gotKeys, want) {
|
|
t.Fatalf("candidate keys = %#v, want %#v", gotKeys, want)
|
|
}
|
|
if want := []string{"stdio", "http", "http", "stdio", "http"}; !reflect.DeepEqual(gotKinds, want) {
|
|
t.Fatalf("candidate transports = %#v, want %#v", gotKinds, want)
|
|
}
|
|
}
|
|
|
|
func TestPluginDescriptorBlankIdentityAndDistributionOwnership(t *testing.T) {
|
|
isolatePluginRuntime(t)
|
|
|
|
blank := mcptypes.ServerDescriptor{
|
|
Key: " ",
|
|
CLI: mcptypes.CLIOverlay{
|
|
ID: " ",
|
|
Command: " ",
|
|
Aliases: []string{"", " "},
|
|
},
|
|
}
|
|
if claims := pluginDescriptorIdentityClaims(blank); len(claims) != 0 {
|
|
t.Fatalf("blank descriptor claims = %#v, want none", claims)
|
|
}
|
|
if rootName := pluginDescriptorRootName(blank); rootName != "" {
|
|
t.Fatalf("blank descriptor root = %q", rootName)
|
|
}
|
|
owner := &plugin.Plugin{Manifest: plugin.Manifest{Name: "blank"}}
|
|
accepted := selectPluginServerCandidates(
|
|
&cobra.Command{Use: "dws"},
|
|
[]pluginServerCandidate{
|
|
{owner: owner, descriptor: mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}},
|
|
{owner: owner, descriptor: blank},
|
|
},
|
|
)
|
|
if len(accepted) != 1 {
|
|
t.Fatalf("blank descriptor candidates = %#v, want one accepted candidate", accepted)
|
|
}
|
|
|
|
if distributionRootOwns(nil, "visible") {
|
|
t.Fatal("nil root claimed a command")
|
|
}
|
|
root := &cobra.Command{Use: "dws"}
|
|
visible := &cobra.Command{Use: "visible", Aliases: []string{" visible-alias "}}
|
|
hiddenFallback := &cobra.Command{Use: "conference", Hidden: true}
|
|
hiddenOwned := &cobra.Command{Use: "hidden-owned", Hidden: true}
|
|
pluginOwned := &cobra.Command{Use: "plugin-owned", Aliases: []string{"plugin-alias"}}
|
|
cmdutil.MarkPluginSource(pluginOwned)
|
|
root.AddCommand(visible, hiddenFallback, hiddenOwned, pluginOwned)
|
|
|
|
for _, name := range []string{"visible", "visible-alias", "hidden-owned"} {
|
|
if !distributionRootOwns(root, name) {
|
|
t.Errorf("distribution root did not claim %q", name)
|
|
}
|
|
}
|
|
for _, name := range []string{"conference", "plugin-owned", "plugin-alias", "missing"} {
|
|
if distributionRootOwns(root, name) {
|
|
t.Errorf("distribution root unexpectedly claimed %q", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReplaceableFallbackIdentitySurvivesDistributionConflictChecks(t *testing.T) {
|
|
isolatePluginRuntime(t)
|
|
SetDynamicServers([]mcptypes.ServerDescriptor{
|
|
{
|
|
Key: "conference",
|
|
Endpoint: "https://example.com/conference/mcp",
|
|
CLI: mcptypes.CLIOverlay{ID: "conference"},
|
|
},
|
|
{
|
|
Key: "chat",
|
|
Endpoint: "https://example.com/chat/mcp",
|
|
CLI: mcptypes.CLIOverlay{ID: "chat"},
|
|
},
|
|
})
|
|
|
|
root := &cobra.Command{Use: "dws"}
|
|
root.AddCommand(&cobra.Command{Use: "conference", Hidden: true})
|
|
distributionProducts := DirectRuntimeProductIDs()
|
|
|
|
conferenceDescriptor := mcptypes.ServerDescriptor{
|
|
Key: "conference-local",
|
|
DisplayName: "conference/conference-local",
|
|
CLI: mcptypes.CLIOverlay{ID: "conference-local", Command: "conference"},
|
|
}
|
|
if pluginDescriptorConflictsWithDistribution(root, conferenceDescriptor, distributionProducts) {
|
|
t.Fatal("replaceable fallback identity blocked plugin server selection")
|
|
}
|
|
|
|
chatDescriptor := mcptypes.ServerDescriptor{
|
|
Key: "chat-local",
|
|
DisplayName: "chat/chat-local",
|
|
CLI: mcptypes.CLIOverlay{ID: "chat-local", Command: "chat"},
|
|
}
|
|
if !pluginDescriptorConflictsWithDistribution(root, chatDescriptor, distributionProducts) {
|
|
t.Fatal("non-replaceable distribution product no longer conflicts")
|
|
}
|
|
|
|
reservedDescriptor := mcptypes.ServerDescriptor{
|
|
Key: "auth-local",
|
|
DisplayName: "auth/auth-local",
|
|
CLI: mcptypes.CLIOverlay{ID: "auth-local", Command: "auth"},
|
|
}
|
|
if !pluginDescriptorConflictsWithDistribution(root, reservedDescriptor, distributionProducts) {
|
|
t.Fatal("reserved command name no longer conflicts")
|
|
}
|
|
|
|
first := &plugin.Plugin{Manifest: plugin.Manifest{Name: "conference"}}
|
|
second := &plugin.Plugin{Manifest: plugin.Manifest{Name: "other"}}
|
|
accepted := selectPluginServerCandidates(root, []pluginServerCandidate{
|
|
{owner: first, descriptor: conferenceDescriptor},
|
|
{
|
|
owner: second,
|
|
descriptor: mcptypes.ServerDescriptor{
|
|
Key: "conference-other",
|
|
DisplayName: "other/conference-other",
|
|
CLI: mcptypes.CLIOverlay{ID: "conference-other", Command: "conference"},
|
|
},
|
|
},
|
|
})
|
|
if len(accepted) != 1 {
|
|
t.Fatalf("accepted candidates = %d, want the first conference plugin only", len(accepted))
|
|
}
|
|
if accepted[0].owner != first {
|
|
t.Fatalf("accepted owner = %q, want the first conference plugin", accepted[0].owner.Manifest.Name)
|
|
}
|
|
}
|
|
|
|
func TestAddPluginCommandsSafeFiltersConflictingAliases(t *testing.T) {
|
|
root := &cobra.Command{Use: "dws"}
|
|
root.AddCommand(&cobra.Command{Use: "taken"})
|
|
command := &cobra.Command{
|
|
Use: "extension",
|
|
Aliases: []string{"", "extension", "auth", "taken", "shared", " shared ", " okay "},
|
|
}
|
|
addPluginCommandsSafe(root, []*cobra.Command{
|
|
command,
|
|
{Use: "shared"},
|
|
{Use: "other", Aliases: []string{"extension"}},
|
|
})
|
|
|
|
if want := []string{"shared", "okay"}; !reflect.DeepEqual(command.Aliases, want) {
|
|
t.Fatalf("filtered aliases = %#v, want %#v", command.Aliases, want)
|
|
}
|
|
if child := findDirectChild(root, "shared"); child != nil {
|
|
t.Fatal("an accepted alias was also registered as a plugin primary command")
|
|
}
|
|
other := findDirectChild(root, "other")
|
|
if other == nil || len(other.Aliases) != 0 {
|
|
t.Fatalf("later plugin aliases = %#v", other)
|
|
}
|
|
}
|
|
|
|
func TestStdioRunnerReportsToolsListFailureAndMissingTool(t *testing.T) {
|
|
isolatePluginRuntime(t)
|
|
previousInit := runnerStdioEnsureInitialized
|
|
previousList := runnerStdioListTools
|
|
previousCall := runnerStdioCallTool
|
|
t.Cleanup(func() {
|
|
runnerStdioEnsureInitialized = previousInit
|
|
runnerStdioListTools = previousList
|
|
runnerStdioCallTool = previousCall
|
|
})
|
|
|
|
client := transport.NewStdioClient("unused", nil, nil)
|
|
RegisterStdioClient("plugin/server", client)
|
|
runnerStdioEnsureInitialized = func(*transport.StdioClient, context.Context) error { return nil }
|
|
toolCalls := 0
|
|
runnerStdioCallTool = func(*transport.StdioClient, context.Context, string, map[string]any) (transport.ToolCallResult, error) {
|
|
toolCalls++
|
|
return transport.ToolCallResult{}, nil
|
|
}
|
|
runner := &runtimeRunner{}
|
|
invocation := executor.Invocation{CanonicalProduct: "overlay-id", Tool: "wanted"}
|
|
|
|
listFailure := errors.New("list failed")
|
|
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
|
|
return transport.ToolsListResult{}, listFailure
|
|
}
|
|
_, err := runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
|
|
assertPluginRuntimeError(t, err, apperrors.CategoryAPI, "tools/list", "stdio_tools_list_error")
|
|
|
|
runnerStdioListTools = func(*transport.StdioClient, context.Context) (transport.ToolsListResult, error) {
|
|
return transport.ToolsListResult{Tools: []transport.ToolDescriptor{{Name: "other"}}}, nil
|
|
}
|
|
_, err = runner.executeStdioInvocationAtEndpoint(context.Background(), "stdio://plugin/server", invocation)
|
|
assertPluginRuntimeError(t, err, apperrors.CategoryValidation, "", "plugin_tool_not_found")
|
|
if toolCalls != 0 {
|
|
t.Fatalf("tools/call attempts after tools/list failures = %d", toolCalls)
|
|
}
|
|
}
|
|
|
|
func TestStdioManifestDescriptorAndRegistrationFailClosed(t *testing.T) {
|
|
isolatePluginRuntime(t)
|
|
p := &plugin.Plugin{
|
|
Manifest: plugin.Manifest{
|
|
Name: "broken-plugin",
|
|
MCPServers: map[string]*plugin.MCPServer{
|
|
"local": {CLI: json.RawMessage(`{`)},
|
|
},
|
|
},
|
|
}
|
|
server := plugin.StdioServerClient{
|
|
Key: "local",
|
|
Client: transport.NewStdioClient("unused", nil, nil),
|
|
}
|
|
if descriptor, ok := stdioServerDescriptorFromManifest(p, server); ok || !reflect.ValueOf(descriptor).IsZero() {
|
|
t.Fatalf("invalid descriptor = (%#v, %v), want zero, false", descriptor, ok)
|
|
}
|
|
if descriptor := registerStdioServerFromManifest(p, server); !reflect.ValueOf(descriptor).IsZero() {
|
|
t.Fatalf("invalid registered descriptor = %#v, want zero", descriptor)
|
|
}
|
|
if _, ok := LookupStdioClient("broken-plugin/local"); ok {
|
|
t.Fatal("invalid stdio manifest registered a client")
|
|
}
|
|
}
|
|
|
|
func TestLegacyCommandsContinueWhenUserShortcutLoadFails(t *testing.T) {
|
|
configDir := t.TempDir()
|
|
t.Setenv("DWS_CONFIG_DIR", configDir)
|
|
shortcutDir := filepath.Join(configDir, "shortcuts")
|
|
if err := os.MkdirAll(shortcutDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(shortcutDir, "broken.yaml"), []byte("version: ["), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, loadErrors := userdef.Load(); len(loadErrors) == 0 {
|
|
t.Fatal("malformed shortcut fixture did not fail to load")
|
|
}
|
|
|
|
runner := executor.EchoRunner{}
|
|
caller := newToolCallerAdapter(runner, &GlobalFlags{})
|
|
if commands := newLegacyPublicCommands(runner, caller, true); len(commands) == 0 {
|
|
t.Fatal("legacy commands were dropped after a user shortcut load error")
|
|
}
|
|
}
|
|
|
|
func findDirectChild(root *cobra.Command, name string) *cobra.Command {
|
|
for _, command := range root.Commands() {
|
|
if command.Name() == name {
|
|
return command
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func assertPluginRuntimeError(
|
|
t *testing.T,
|
|
err error,
|
|
wantCategory apperrors.Category,
|
|
wantOperation string,
|
|
wantReason string,
|
|
) {
|
|
t.Helper()
|
|
var appError *apperrors.Error
|
|
if !errors.As(err, &appError) {
|
|
t.Fatalf("runtime error = %#v, want structured app error", err)
|
|
}
|
|
if appError.Category != wantCategory ||
|
|
appError.Operation != wantOperation ||
|
|
appError.Reason != wantReason {
|
|
t.Fatalf("runtime error = %#v, want category=%q operation=%q reason=%q", appError, wantCategory, wantOperation, wantReason)
|
|
}
|
|
}
|