Files
dingtalk-workspace-cli/internal/market/registry_test.go
T
2026-03-27 17:55:57 +08:00

330 lines
9.4 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 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" {
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" {
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
}