Files
dingtalk-workspace-cli/internal/market/registry_test.go
修雨 4c5f1faeb1 feat: align open CLI with dws-wukong via cedar discovery version code (#509)
Switches the discovery version code bamboo -> cedar and aligns the open edition CLI with dws-wukong across communication (calendar book/acl/attendee, minutes tag, mail folder/template/contact, chat file upload, todo add-attachment, attendance transforms) and structured-office (aitable advperm/view/section/workflow/record, sheet/drive/wiki/doc) domains. Includes output-envelope parity, parse_bool/attendance_class_check_time transforms, --calendar-id support, CHANGELOG 1.0.43 and README command-index refresh. cedar config validated on pre and prod endpoints.
2026-06-26 21:49:38 +08:00

457 lines
14 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 market
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNormalizeServersDeduplicatesByNewestEndpoint(t *testing.T) {
t.Parallel()
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "钉钉文档",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc"},
},
},
Meta: EnvelopeMeta{Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-16T00:00:00Z"}},
},
{
Server: RegistryServer{
Name: "钉钉文档",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc"},
},
},
Meta: EnvelopeMeta{Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-18T00:00:00Z"}},
},
{
Server: RegistryServer{
Name: "停用服务",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/inactive"},
},
},
Meta: EnvelopeMeta{Registry: RegistryMetadata{Status: "inactive", UpdatedAt: "2026-03-18T00:00:00Z"}},
},
},
}
servers := NormalizeServers(response, "live_market")
if len(servers) != 1 {
t.Fatalf("NormalizeServers() len = %d, want 1", len(servers))
}
if !servers[0].UpdatedAt.Equal(mustParseRFC3339(t, "2026-03-18T00:00:00Z")) {
t.Fatalf("NormalizeServers() picked updatedAt %s, want newest record", servers[0].UpdatedAt)
}
}
func TestNormalizeServersDeduplicatesSameNameAcrossEndpoints(t *testing.T) {
t.Parallel()
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "钉钉文档",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc-a"},
},
},
Meta: EnvelopeMeta{Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-18T00:00:00Z"}},
},
{
Server: RegistryServer{
Name: "钉钉文档",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc-b"},
},
},
Meta: EnvelopeMeta{Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-19T00:00:00Z"}},
},
},
}
servers := NormalizeServers(response, "live_market")
if len(servers) != 1 {
t.Fatalf("NormalizeServers() len = %d, want 1 newest-by-name record", len(servers))
}
if servers[0].Endpoint != "https://example.com/server/doc-b" {
t.Fatalf("NormalizeServers() endpoint = %q, want newest endpoint doc-b", servers[0].Endpoint)
}
}
func TestNormalizeServersConvertsPreMCPRemoteToGatewayEndpoint(t *testing.T) {
t.Parallel()
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "钉钉开放平台文档搜索",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://pre-mcp.dingtalk.com/server/devdoc?key=b&key=a#fragment"},
},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-06-16T00:00:00Z"},
CLI: CLIOverlay{ID: "devdoc", Command: "devdoc"},
},
},
},
}
servers := NormalizeServers(response, "live_market")
if len(servers) != 1 {
t.Fatalf("NormalizeServers() len = %d, want 1", len(servers))
}
want := "https://pre-mcp-gw.dingtalk.com/server/devdoc?key=a&key=b"
if servers[0].Endpoint != want {
t.Fatalf("NormalizeServers() endpoint = %q, want %q", servers[0].Endpoint, want)
}
}
func TestNormalizeServersForBaseURLForcesPreGatewayEndpoint(t *testing.T) {
t.Parallel()
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "钉钉文档",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://mcp-gw.dingtalk.com/server/doc?key=b&key=a#fragment"},
},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-06-16T00:00:00Z"},
CLI: CLIOverlay{ID: "doc", Command: "doc"},
},
},
},
}
prodServers := NormalizeServers(response, "live_market")
if len(prodServers) != 1 {
t.Fatalf("NormalizeServers() len = %d, want 1", len(prodServers))
}
if want := "https://mcp-gw.dingtalk.com/server/doc?key=a&key=b"; prodServers[0].Endpoint != want {
t.Fatalf("NormalizeServers() endpoint = %q, want %q", prodServers[0].Endpoint, want)
}
preServers := NormalizeServersForBaseURL(response, "live_market", "https://pre-mcp.dingtalk.com")
if len(preServers) != 1 {
t.Fatalf("NormalizeServersForBaseURL() len = %d, want 1", len(preServers))
}
want := "https://pre-mcp-gw.dingtalk.com/server/doc?key=a&key=b"
if preServers[0].Endpoint != want {
t.Fatalf("NormalizeServersForBaseURL() endpoint = %q, want %q", preServers[0].Endpoint, want)
}
}
// TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint guards the
// bot-root / bot-message / bot-group split (issue: chat bot subtree vanished
// from the CLI after NormalizeServers collapsed three envelopes that share a
// single MCP endpoint and displayName but expose different cli.id values).
func TestNormalizeServersPreservesDistinctCLIIDsOnSharedEndpoint(t *testing.T) {
t.Parallel()
sharedURL := "https://example.com/server/4717"
sharedName := "机器人消息"
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-root", Command: "bot"},
},
},
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-message", Command: "message"},
},
},
{
Server: RegistryServer{
Name: sharedName,
Remotes: []RegistryRemote{{Type: "streamable-http", URL: sharedURL}},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{Status: "active", UpdatedAt: "2026-03-29T00:00:00Z"},
CLI: CLIOverlay{ID: "bot-group", Command: "group"},
},
},
},
}
servers := NormalizeServers(response, "live_market")
if len(servers) != 3 {
t.Fatalf("NormalizeServers() len = %d, want 3 (one per cli.id)", len(servers))
}
seen := map[string]bool{}
for _, s := range servers {
seen[s.CLI.ID] = true
}
for _, want := range []string{"bot-root", "bot-message", "bot-group"} {
if !seen[want] {
t.Errorf("NormalizeServers() missing descriptor with cli.id %q", want)
}
}
}
func TestNormalizeServersMarksLegacyNameAsDeprecatedCandidate(t *testing.T) {
t.Parallel()
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "钉钉文档(旧)",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc-legacy"},
},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{
Status: "active",
},
},
},
},
}
servers := NormalizeServers(response, "live_market")
if len(servers) != 1 {
t.Fatalf("NormalizeServers() len = %d, want 1", len(servers))
}
if !servers[0].Lifecycle.DeprecatedCandidate {
t.Fatalf("NormalizeServers() deprecatedCandidate = false, want true")
}
}
func TestFetchServers(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/discovery/apis/cedar" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
payload := ListResponse{
Metadata: ListMetadata{Count: 1},
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "文档",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc"},
},
},
Meta: EnvelopeMeta{Registry: RegistryMetadata{Status: "active"}},
},
},
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("encode payload: %v", err)
}
}))
defer server.Close()
client := NewClient(server.URL, server.Client())
response, err := client.FetchServers(context.Background(), 200)
if err != nil {
t.Fatalf("FetchServers() error = %v", err)
}
if response.Metadata.Count != 1 {
t.Fatalf("FetchServers() count = %d, want 1", response.Metadata.Count)
}
}
func TestFetchServersFollowsNextCursor(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cli/discovery/apis/cedar" {
t.Fatalf("unexpected path %q", r.URL.Path)
}
cursor := r.URL.Query().Get("cursor")
switch cursor {
case "":
payload := ListResponse{
Metadata: ListMetadata{Count: 1, NextCursor: "page-2"},
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "文档一",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc-1"},
},
},
Meta: EnvelopeMeta{Registry: RegistryMetadata{Status: "active"}},
},
},
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("encode first payload: %v", err)
}
case "page-2":
payload := ListResponse{
Metadata: ListMetadata{Count: 1},
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "文档二",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc-2"},
},
},
Meta: EnvelopeMeta{Registry: RegistryMetadata{Status: "active"}},
},
},
}
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("encode second payload: %v", err)
}
default:
t.Fatalf("unexpected cursor %q", cursor)
}
}))
defer server.Close()
client := NewClient(server.URL, server.Client())
response, err := client.FetchServers(context.Background(), 200)
if err != nil {
t.Fatalf("FetchServers() error = %v", err)
}
if response.Metadata.Count != 2 {
t.Fatalf("FetchServers() count = %d, want 2", response.Metadata.Count)
}
if len(response.Servers) != 2 {
t.Fatalf("FetchServers() len = %d, want 2", len(response.Servers))
}
if response.Metadata.NextCursor != "" {
t.Fatalf("FetchServers() nextCursor = %q, want empty after aggregation", response.Metadata.NextCursor)
}
}
func TestFetchDetailByURLRejectsUnsafeTargets(t *testing.T) {
t.Parallel()
client := NewClient("https://mcp.dingtalk.com", nil)
// Private IP — should be rejected
_, err := client.FetchDetailByURL(context.Background(), "https://127.0.0.1/detail")
if err == nil {
t.Fatal("FetchDetailByURL(loopback) should fail with SSRF guard")
}
// localhost hostname — should be rejected
_, err = client.FetchDetailByURL(context.Background(), "https://localhost/detail")
if err == nil {
t.Fatal("FetchDetailByURL(localhost) should fail with SSRF guard")
}
// Non-HTTPS — should be rejected
_, err = client.FetchDetailByURL(context.Background(), "http://mcp.dingtalk.com/detail")
if err == nil {
t.Fatal("FetchDetailByURL(http) should fail with HTTPS guard")
}
// Relative URL resolved against HTTPS BaseURL passes scheme check but
// the detail endpoint in the test environment is unreachable. We only
// verify it does NOT return an SSRF error.
_, err = client.FetchDetailByURL(context.Background(), "/mcp/market/detail?mcpId=1")
if err != nil && err.Error() == "market detail URL must not target private network addresses" {
t.Fatal("FetchDetailByURL(relative) should not fail with SSRF guard")
}
}
func TestNormalizeServersPreservesCLIMetadataAndLifecycle(t *testing.T) {
t.Parallel()
response := ListResponse{
Servers: []ServerEnvelope{
{
Server: RegistryServer{
Name: "钉钉文档",
Remotes: []RegistryRemote{
{Type: "streamable-http", URL: "https://example.com/server/doc"},
},
},
Meta: EnvelopeMeta{
Registry: RegistryMetadata{
Status: "active",
MCPID: 9629,
DetailURL: "https://example.com/detail?mcpId=9629",
Lifecycle: LifecycleInfo{
DeprecatedBy: 0,
DeprecationDate: "",
MigrationURL: "",
},
},
CLI: CLIOverlay{
ID: "doc",
Command: "doc",
Tools: []CLITool{
{Name: "create_document", CLIName: "create-document", Title: "创建文档", IsSensitive: true},
},
},
},
},
},
}
servers := NormalizeServers(response, "live_market")
if len(servers) != 1 {
t.Fatalf("NormalizeServers() len = %d, want 1", len(servers))
}
if !servers[0].HasCLIMeta {
t.Fatalf("NormalizeServers() expected HasCLIMeta")
}
if servers[0].CLI.ID != "doc" || servers[0].CLI.Command != "doc" {
t.Fatalf("NormalizeServers() cli = %#v", servers[0].CLI)
}
if servers[0].DetailLocator.MCPID != 9629 {
t.Fatalf("NormalizeServers() mcpId = %d, want 9629", servers[0].DetailLocator.MCPID)
}
}
func mustParseRFC3339(t *testing.T, value string) (parsed time.Time) {
t.Helper()
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
t.Fatalf("time.Parse(%q) error = %v", value, err)
}
return parsed
}