Files
dingtalk-workspace-cli/internal/plugin/plugin_test.go
T
修雨 a056a9abfb refactor(plugin): purge removed plugin settings instead of merely disabling
Review follow-up on #126: RemovePlugin used to call
setPluginEnabled(name, false), which left the plugin's key in
EnabledPlugins and never touched PluginConfigs. settings.json retained
dangling state for plugins no longer on disk.

Replace the disable with purgePluginFromSettings, which deletes both
the EnabledPlugins entry and any PluginConfigs entry for the removed
plugin, leaving unrelated plugins' state intact. A re-install defaults
to enabled=true via the existing install paths, matching user
expectations.

Covered by TestRemovePluginPurgesSettings across both user and legacy
managed layouts.
2026-04-19 14:32:22 +08:00

669 lines
17 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.
// 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 plugin
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
func TestParseManifest(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "plugin.json")
content := `{
"name": "conference",
"version": "1.0.0",
"description": "音视频会议",
"type": "managed",
"minCLIVersion": "0.9.0",
"mcpServers": {
"conference": {
"type": "streamable-http",
"endpoint": "https://mcp.conference.dingtalk.com"
},
"conference-local": {
"type": "stdio",
"command": "${DWS_PLUGIN_ROOT}/bin/conference-local",
"args": ["--mode", "cli"]
}
},
"skills": "./skills/"
}`
if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
m, err := ParseManifest(manifestPath)
if err != nil {
t.Fatalf("ParseManifest: %v", err)
}
if m.Name != "conference" {
t.Errorf("name = %q, want conference", m.Name)
}
if m.Version != "1.0.0" {
t.Errorf("version = %q, want 1.0.0", m.Version)
}
if m.Type != "managed" {
t.Errorf("type = %q, want managed", m.Type)
}
if len(m.MCPServers) != 2 {
t.Errorf("mcpServers count = %d, want 2", len(m.MCPServers))
}
if m.MCPServers["conference"].Type != "streamable-http" {
t.Errorf("conference server type = %q, want streamable-http", m.MCPServers["conference"].Type)
}
if m.MCPServers["conference-local"].Type != "stdio" {
t.Errorf("conference-local server type = %q, want stdio", m.MCPServers["conference-local"].Type)
}
}
func TestManifestValidate(t *testing.T) {
tests := []struct {
name string
manifest Manifest
cliVersion string
wantErr bool
}{
{
name: "valid manifest",
manifest: Manifest{
Name: "conference",
Version: "1.0.0",
Type: "managed",
MCPServers: map[string]*MCPServer{
"conference": {Type: "streamable-http", Endpoint: "https://example.com"},
},
},
cliVersion: "1.0.0",
wantErr: false,
},
{
name: "invalid name - too short",
manifest: Manifest{
Name: "ab",
Version: "1.0.0",
},
wantErr: true,
},
{
name: "invalid name - uppercase",
manifest: Manifest{
Name: "MyPlugin",
Version: "1.0.0",
},
wantErr: true,
},
{
name: "invalid version",
manifest: Manifest{
Name: "my-plugin",
Version: "not-semver",
},
wantErr: true,
},
{
name: "invalid type",
manifest: Manifest{
Name: "my-plugin",
Version: "1.0.0",
Type: "invalid",
},
wantErr: true,
},
{
name: "cli version too low",
manifest: Manifest{
Name: "my-plugin",
Version: "1.0.0",
MinCLIVersion: "2.0.0",
},
cliVersion: "1.0.0",
wantErr: true,
},
{
name: "streamable-http without endpoint",
manifest: Manifest{
Name: "my-plugin",
Version: "1.0.0",
MCPServers: map[string]*MCPServer{
"srv": {Type: "streamable-http"},
},
},
wantErr: true,
},
{
name: "stdio without command",
manifest: Manifest{
Name: "my-plugin",
Version: "1.0.0",
MCPServers: map[string]*MCPServer{
"srv": {Type: "stdio"},
},
},
wantErr: true,
},
{
name: "unsafe skills path",
manifest: Manifest{
Name: "my-plugin",
Version: "1.0.0",
Skills: "../../../etc/passwd",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.manifest.Validate(tt.cliVersion)
if (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestPluginToServerDescriptors(t *testing.T) {
cliOverlay, _ := json.Marshal(map[string]any{
"id": "conference",
"command": "conference",
})
p := &Plugin{
Manifest: Manifest{
Name: "conference",
Description: "音视频会议",
MCPServers: map[string]*MCPServer{
"conference": {
Type: "streamable-http",
Endpoint: "https://mcp.conference.dingtalk.com",
CLI: cliOverlay,
},
"conference-local": {
Type: "stdio",
Command: "/usr/local/bin/conference-local",
},
},
},
Root: "/tmp/plugins/conference",
IsManaged: true,
}
descriptors := p.ToServerDescriptors()
// Only streamable-http should be converted
if len(descriptors) != 1 {
t.Fatalf("got %d descriptors, want 1 (stdio should be skipped)", len(descriptors))
}
d := descriptors[0]
if d.Key != "conference" {
t.Errorf("key = %q, want conference", d.Key)
}
if d.Endpoint != "https://mcp.conference.dingtalk.com" {
t.Errorf("endpoint = %q", d.Endpoint)
}
if d.Source != "plugin-managed" {
t.Errorf("source = %q, want plugin-managed", d.Source)
}
if d.CLI.ID != "conference" {
t.Errorf("cli.id = %q, want conference", d.CLI.ID)
}
}
func TestPluginToServerDescriptorsWithHeaders(t *testing.T) {
cliOverlay, _ := json.Marshal(map[string]any{
"id": "web-search",
"command": "web-search",
})
// Set an environment variable to test expansion
t.Setenv("TEST_API_KEY", "sk-test-12345")
p := &Plugin{
Manifest: Manifest{
Name: "my-plugin",
Description: "Test plugin with headers",
MCPServers: map[string]*MCPServer{
"web-search": {
Type: "streamable-http",
Endpoint: "https://api.example.com/mcp/v1",
CLI: cliOverlay,
Headers: map[string]string{
"Authorization": "Bearer ${TEST_API_KEY}",
"X-Custom": "static-value",
},
},
},
},
Root: "/tmp/plugins/my-plugin",
}
descriptors := p.ToServerDescriptors()
if len(descriptors) != 1 {
t.Fatalf("got %d descriptors, want 1", len(descriptors))
}
d := descriptors[0]
if d.Key != "web-search" {
t.Errorf("key = %q, want web-search", d.Key)
}
if len(d.AuthHeaders) != 2 {
t.Fatalf("AuthHeaders len = %d, want 2", len(d.AuthHeaders))
}
// Environment variable should be expanded
if d.AuthHeaders["Authorization"] != "Bearer sk-test-12345" {
t.Errorf("AuthHeaders[Authorization] = %q, want 'Bearer sk-test-12345'", d.AuthHeaders["Authorization"])
}
if d.AuthHeaders["X-Custom"] != "static-value" {
t.Errorf("AuthHeaders[X-Custom] = %q, want static-value", d.AuthHeaders["X-Custom"])
}
if d.Source != "plugin" {
t.Errorf("source = %q, want plugin (non-managed)", d.Source)
}
}
func TestPluginToServerDescriptorsNoHeaders(t *testing.T) {
cliOverlay, _ := json.Marshal(map[string]any{
"id": "conference",
"command": "conference",
})
p := &Plugin{
Manifest: Manifest{
Name: "conference",
MCPServers: map[string]*MCPServer{
"conference": {
Type: "streamable-http",
Endpoint: "https://mcp.conference.dingtalk.com",
CLI: cliOverlay,
},
},
},
Root: "/tmp/plugins/conference",
}
descriptors := p.ToServerDescriptors()
if len(descriptors) != 1 {
t.Fatalf("got %d descriptors, want 1", len(descriptors))
}
if descriptors[0].AuthHeaders != nil {
t.Errorf("AuthHeaders = %v, want nil for server without headers", descriptors[0].AuthHeaders)
}
}
func TestParseManifestWithHeaders(t *testing.T) {
dir := t.TempDir()
manifestPath := filepath.Join(dir, "plugin.json")
content := `{
"name": "api-plugin",
"version": "1.0.0",
"mcpServers": {
"api-server": {
"type": "streamable-http",
"endpoint": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer ${MY_API_KEY}",
"X-Custom-Header": "custom-value"
}
}
}
}`
if err := os.WriteFile(manifestPath, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
m, err := ParseManifest(manifestPath)
if err != nil {
t.Fatalf("ParseManifest: %v", err)
}
srv := m.MCPServers["api-server"]
if srv == nil {
t.Fatal("api-server not found in MCPServers")
}
if len(srv.Headers) != 2 {
t.Fatalf("Headers len = %d, want 2", len(srv.Headers))
}
if srv.Headers["Authorization"] != "Bearer ${MY_API_KEY}" {
t.Errorf("Headers[Authorization] = %q, want raw template", srv.Headers["Authorization"])
}
if srv.Headers["X-Custom-Header"] != "custom-value" {
t.Errorf("Headers[X-Custom-Header] = %q, want custom-value", srv.Headers["X-Custom-Header"])
}
}
func TestLoaderScanEmpty(t *testing.T) {
dir := t.TempDir()
loader := &Loader{
PluginsDir: dir,
CLIVersion: "1.0.0",
}
managed := loader.LoadManaged()
if len(managed) != 0 {
t.Errorf("expected 0 managed plugins, got %d", len(managed))
}
user := loader.LoadUser()
if len(user) != 0 {
t.Errorf("expected 0 user plugins, got %d", len(user))
}
}
func TestLoaderLoadManaged(t *testing.T) {
dir := t.TempDir()
managedDir := filepath.Join(dir, "managed", "conference")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{
"name": "conference",
"version": "1.0.0",
"type": "managed",
"mcpServers": {
"conference": {
"type": "streamable-http",
"endpoint": "https://example.com"
}
}
}`
if err := os.WriteFile(filepath.Join(managedDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
plugins := loader.LoadManaged()
if len(plugins) != 1 {
t.Fatalf("expected 1 managed plugin, got %d", len(plugins))
}
if plugins[0].Manifest.Name != "conference" {
t.Errorf("name = %q, want conference", plugins[0].Manifest.Name)
}
if !plugins[0].IsManaged {
t.Error("expected IsManaged = true")
}
}
// TestRemoveLegacyManagedPlugin ensures plugins that were installed under
// the legacy ~/.dws/plugins/managed/ directory are now freely removable
// — the old "cannot be removed" privilege has been dropped.
func TestRemoveLegacyManagedPlugin(t *testing.T) {
dir := t.TempDir()
managedDir := filepath.Join(dir, "managed", "conference")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(managedDir, "plugin.json"), []byte(`{"name":"conference","version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
if err := loader.RemovePlugin("conference", false); err != nil {
t.Fatalf("unexpected error removing legacy managed plugin: %v", err)
}
if _, err := os.Stat(managedDir); !os.IsNotExist(err) {
t.Errorf("managed plugin dir should be removed, stat err = %v", err)
}
}
// TestRemovePluginPurgesSettings verifies RemovePlugin fully purges the
// plugin's settings — both its enabled flag and any pluginConfigs entry —
// so settings.json does not retain dangling state for a plugin that no
// longer exists on disk. Covers both the user and legacy managed paths.
func TestRemovePluginPurgesSettings(t *testing.T) {
cases := []struct {
name string
layout string // "user" or "legacy"
pkgName string
}{
{name: "user plugin", layout: "user", pkgName: "my-plugin"},
{name: "legacy managed plugin", layout: "legacy", pkgName: "conference"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
var pluginDir string
switch tc.layout {
case "user":
pluginDir = filepath.Join(dir, "user", tc.pkgName)
case "legacy":
pluginDir = filepath.Join(dir, "managed", tc.pkgName)
}
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"),
[]byte(`{"name":"`+tc.pkgName+`","version":"1.0.0"}`), 0o644); err != nil {
t.Fatal(err)
}
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
// Seed settings.json with an explicit enabled flag and a
// pluginConfigs entry to verify both get purged.
settings := &Settings{
EnabledPlugins: map[string]bool{tc.pkgName: true, "other-plugin": true},
PluginConfigs: map[string]map[string]any{
tc.pkgName: {"API_KEY": "secret"},
"other-plugin": {"TOKEN": "keep-me"},
},
}
loader.saveSettings(settings)
if err := loader.RemovePlugin(tc.pkgName, false); err != nil {
t.Fatalf("RemovePlugin: %v", err)
}
reloaded := loader.loadSettings()
if _, exists := reloaded.EnabledPlugins[tc.pkgName]; exists {
t.Errorf("EnabledPlugins should not retain removed plugin %q", tc.pkgName)
}
if _, exists := reloaded.PluginConfigs[tc.pkgName]; exists {
t.Errorf("PluginConfigs should not retain removed plugin %q", tc.pkgName)
}
if !reloaded.EnabledPlugins["other-plugin"] {
t.Error("unrelated EnabledPlugins entry should be preserved")
}
if reloaded.PluginConfigs["other-plugin"]["TOKEN"] != "keep-me" {
t.Error("unrelated PluginConfigs entry should be preserved")
}
})
}
}
func TestIsPluginEnabled(t *testing.T) {
s := &Settings{
EnabledPlugins: map[string]bool{
"my-plugin": true,
"disabled": false,
},
}
if !isPluginEnabled(s, "my-plugin") {
t.Error("my-plugin should be enabled")
}
if isPluginEnabled(s, "disabled") {
t.Error("disabled should not be enabled")
}
if !isPluginEnabled(s, "not-in-list") {
t.Error("unlisted plugin should default to enabled")
}
if !isPluginEnabled(nil, "anything") {
t.Error("nil settings should default to enabled")
}
}
func TestParseGitURL(t *testing.T) {
tests := []struct {
name string
url string
wantWS string
wantRepo string
wantErr bool
}{
{
name: "https with .git",
url: "https://github.com/PeterGuy326/hello-plugin.git",
wantWS: "PeterGuy326",
wantRepo: "hello-plugin",
},
{
name: "https without .git",
url: "https://github.com/DingTalk-Real-AI/conference",
wantWS: "DingTalk-Real-AI",
wantRepo: "conference",
},
{
name: "ssh format",
url: "git@github.com:DingTalk-Real-AI/conference.git",
wantWS: "DingTalk-Real-AI",
wantRepo: "conference",
},
{
name: "invalid - no repo",
url: "https://github.com/onlyone",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ws, repo, err := parseGitURL(tt.url)
if (err != nil) != tt.wantErr {
t.Errorf("parseGitURL() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr {
if ws != tt.wantWS {
t.Errorf("workspace = %q, want %q", ws, tt.wantWS)
}
if repo != tt.wantRepo {
t.Errorf("repo = %q, want %q", repo, tt.wantRepo)
}
}
})
}
}
func TestDevPluginRegistration(t *testing.T) {
dir := t.TempDir()
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
// Create a dev plugin directory
devDir := filepath.Join(t.TempDir(), "my-dev-plugin")
if err := os.MkdirAll(devDir, 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"name":"my-dev-plugin","version":"0.1.0","type":"user"}`
if err := os.WriteFile(filepath.Join(devDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
// Register dev plugin
if err := loader.RegisterDevPlugin("my-dev-plugin", devDir); err != nil {
t.Fatalf("RegisterDevPlugin: %v", err)
}
// Load dev plugins
plugins := loader.LoadDev()
if len(plugins) != 1 {
t.Fatalf("expected 1 dev plugin, got %d", len(plugins))
}
if plugins[0].Manifest.Name != "my-dev-plugin" {
t.Errorf("name = %q, want my-dev-plugin", plugins[0].Manifest.Name)
}
if plugins[0].Root != devDir {
t.Errorf("root = %q, want %q (should load from source dir, not copy)", plugins[0].Root, devDir)
}
// Unregister
if err := loader.UnregisterDevPlugin("my-dev-plugin"); err != nil {
t.Fatalf("UnregisterDevPlugin: %v", err)
}
// Should be empty now
plugins = loader.LoadDev()
if len(plugins) != 0 {
t.Errorf("expected 0 dev plugins after unregister, got %d", len(plugins))
}
}
func TestUnregisterDevPluginNotFound(t *testing.T) {
dir := t.TempDir()
loader := &Loader{PluginsDir: dir, CLIVersion: "1.0.0"}
err := loader.UnregisterDevPlugin("nonexistent")
if err == nil {
t.Error("expected error when unregistering nonexistent dev plugin")
}
}
func TestSyncSkills(t *testing.T) {
// Create a plugin with skills
pluginDir := t.TempDir()
skillsDir := filepath.Join(pluginDir, "skills", "test-plugin")
if err := os.MkdirAll(skillsDir, 0o755); err != nil {
t.Fatal(err)
}
skillContent := "# Test Plugin Skill"
if err := os.WriteFile(filepath.Join(skillsDir, "SKILL.md"), []byte(skillContent), 0o644); err != nil {
t.Fatal(err)
}
p := &Plugin{
Manifest: Manifest{
Name: "test-plugin",
Skills: "./skills/test-plugin",
},
Root: pluginDir,
}
// Create a mock agent directory
home, _ := os.UserHomeDir()
agentDir := filepath.Join(home, ".agents", "skills")
// Only run if .agents exists (don't create in CI)
if _, err := os.Stat(filepath.Dir(agentDir)); err == nil {
SyncSkills([]*Plugin{p})
synced := filepath.Join(agentDir, "dws", "plugins", "test-plugin", "SKILL.md")
if _, err := os.Stat(synced); err == nil {
data, _ := os.ReadFile(synced)
if string(data) != skillContent {
t.Errorf("synced content = %q, want %q", string(data), skillContent)
}
// Cleanup
os.RemoveAll(filepath.Join(agentDir, "dws", "plugins", "test-plugin"))
}
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsSubstring(s, substr))
}
func containsSubstring(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}