mirror of
https://github.com/alibaba/higress.git
synced 2026-06-26 10:45:25 +08:00
Signed-off-by: jingze <daijingze.djz@alibaba-inc.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
324 lines
11 KiB
Go
324 lines
11 KiB
Go
// Copyright (c) 2022 Alibaba Group Holding Ltd.
|
|
//
|
|
// 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 server
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// NewMcpProtocolHandler
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestNewMcpProtocolHandler(t *testing.T) {
|
|
h := NewMcpProtocolHandler("http://backend.example/mcp", 5000)
|
|
require.NotNil(t, h)
|
|
assert.Equal(t, "http://backend.example/mcp", h.backendURL)
|
|
assert.Equal(t, 5000, h.timeout)
|
|
assert.Empty(t, h.sessionID, "fresh handler has no session id until Initialize runs")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// parseSSEResponse — fill the remaining branches
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestParseSSEResponse_OnlyCommentsAndBlanks(t *testing.T) {
|
|
// All non-data lines → must surface "no data field found".
|
|
_, err := parseSSEResponse([]byte(": only a comment\n\n: another\n"))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "no data field")
|
|
}
|
|
|
|
func TestParseSSEResponse_TooLongLine(t *testing.T) {
|
|
// Single data line larger than the scanner's 32MB max-token cap.
|
|
big := strings.Repeat("x", 33*1024*1024)
|
|
_, err := parseSSEResponse([]byte("data: " + big + "\n\n"))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "32MB", "must surface the max-token overflow as a clear error")
|
|
}
|
|
|
|
func TestParseSSEResponse_MultipleDataLinesReturnsFirst(t *testing.T) {
|
|
body := "data: first\n\ndata: second\n\n"
|
|
out, err := parseSSEResponse([]byte(body))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "first", string(out), "the function returns the first data line and stops")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// createInitializeRequest / createToolsListRequest / createToolsCallRequest
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestCreateInitializeRequest_StableShape(t *testing.T) {
|
|
h := NewMcpProtocolHandler("http://backend.example/mcp", 5000)
|
|
req := h.createInitializeRequest()
|
|
|
|
assert.Equal(t, "2.0", req["jsonrpc"])
|
|
assert.Equal(t, 1, req["id"])
|
|
assert.Equal(t, "initialize", req["method"])
|
|
|
|
params, ok := req["params"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "2025-03-26", params["protocolVersion"])
|
|
|
|
clientInfo, ok := params["clientInfo"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "Higress-mcp-proxy", clientInfo["name"])
|
|
assert.Equal(t, "1.0.0", clientInfo["version"])
|
|
|
|
_, hasCaps := params["capabilities"]
|
|
assert.True(t, hasCaps)
|
|
}
|
|
|
|
func TestCreateToolsListRequest_NoCursor(t *testing.T) {
|
|
h := NewMcpProtocolHandler("http://backend.example/mcp", 5000)
|
|
req := h.createToolsListRequest(nil)
|
|
|
|
assert.Equal(t, "2.0", req["jsonrpc"])
|
|
assert.Equal(t, 2, req["id"])
|
|
assert.Equal(t, "tools/list", req["method"])
|
|
|
|
params, ok := req["params"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
_, hasCursor := params["cursor"]
|
|
assert.False(t, hasCursor, "nil cursor must produce no cursor field")
|
|
}
|
|
|
|
func TestCreateToolsListRequest_EmptyStringCursor(t *testing.T) {
|
|
h := NewMcpProtocolHandler("http://backend.example/mcp", 5000)
|
|
empty := ""
|
|
req := h.createToolsListRequest(&empty)
|
|
|
|
params := req["params"].(map[string]interface{})
|
|
_, hasCursor := params["cursor"]
|
|
assert.False(t, hasCursor, "empty-string cursor is treated as absent")
|
|
}
|
|
|
|
func TestCreateToolsListRequest_WithCursor(t *testing.T) {
|
|
h := NewMcpProtocolHandler("http://backend.example/mcp", 5000)
|
|
c := "next-page"
|
|
req := h.createToolsListRequest(&c)
|
|
|
|
params := req["params"].(map[string]interface{})
|
|
assert.Equal(t, "next-page", params["cursor"])
|
|
}
|
|
|
|
func TestCreateToolsCallRequest_StableShape(t *testing.T) {
|
|
h := NewMcpProtocolHandler("http://backend.example/mcp", 5000)
|
|
args := map[string]interface{}{"q": "hello", "limit": 5}
|
|
req := h.createToolsCallRequest("search", args)
|
|
|
|
assert.Equal(t, "2.0", req["jsonrpc"])
|
|
assert.Equal(t, 3, req["id"])
|
|
assert.Equal(t, "tools/call", req["method"])
|
|
|
|
params, ok := req["params"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "search", params["name"])
|
|
gotArgs, ok := params["arguments"].(map[string]interface{})
|
|
require.True(t, ok)
|
|
assert.Equal(t, "hello", gotArgs["q"])
|
|
assert.Equal(t, 5, gotArgs["limit"])
|
|
}
|
|
|
|
func TestCreateToolsCallRequest_NilArguments(t *testing.T) {
|
|
h := NewMcpProtocolHandler("http://backend.example/mcp", 5000)
|
|
req := h.createToolsCallRequest("noop", nil)
|
|
params := req["params"].(map[string]interface{})
|
|
assert.Equal(t, "noop", params["name"])
|
|
args, ok := params["arguments"]
|
|
require.True(t, ok)
|
|
assert.Nil(t, args)
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// ParseBackendResponse / IsBackendError — extra branches
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestParseBackendResponse_StringErrorField(t *testing.T) {
|
|
// JSON-RPC error field doesn't have to be an object — anything truthy works.
|
|
body := []byte(`{"jsonrpc":"2.0","id":1,"error":"some-text"}`)
|
|
resp, isErr, etype := ParseBackendResponse(body)
|
|
require.NotNil(t, resp)
|
|
assert.True(t, isErr)
|
|
assert.Equal(t, "jsonrpc_error", etype)
|
|
}
|
|
|
|
func TestParseBackendResponse_NoResultNoError(t *testing.T) {
|
|
// Valid JSON without result/error → not an error, but still parsed.
|
|
body := []byte(`{"jsonrpc":"2.0","id":2}`)
|
|
resp, isErr, etype := ParseBackendResponse(body)
|
|
require.NotNil(t, resp)
|
|
assert.False(t, isErr)
|
|
assert.Empty(t, etype)
|
|
}
|
|
|
|
func TestParseBackendResponse_ResultIsErrorFalseNotAnError(t *testing.T) {
|
|
body := []byte(`{"jsonrpc":"2.0","id":3,"result":{"isError":false}}`)
|
|
_, isErr, etype := ParseBackendResponse(body)
|
|
assert.False(t, isErr)
|
|
assert.Empty(t, etype)
|
|
}
|
|
|
|
func TestParseBackendResponse_ResultNotAnObject(t *testing.T) {
|
|
// result is a scalar — the isError-extraction branch is skipped.
|
|
body := []byte(`{"jsonrpc":"2.0","id":3,"result":"ok"}`)
|
|
_, isErr, etype := ParseBackendResponse(body)
|
|
assert.False(t, isErr)
|
|
assert.Empty(t, etype)
|
|
}
|
|
|
|
func TestIsBackendError_DelegatesToParse(t *testing.T) {
|
|
cases := []struct {
|
|
body string
|
|
isError bool
|
|
etype string
|
|
}{
|
|
{`{"error":{"code":-1}}`, true, "jsonrpc_error"},
|
|
{`{"result":{"isError":true}}`, true, "result_isError"},
|
|
{`{"result":{"isError":false}}`, false, ""},
|
|
{`not json`, false, ""},
|
|
}
|
|
for _, c := range cases {
|
|
isErr, etype := IsBackendError([]byte(c.body))
|
|
assert.Equal(t, c.isError, isErr, "body=%s", c.body)
|
|
assert.Equal(t, c.etype, etype, "body=%s", c.body)
|
|
}
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// McpSessionManagerImpl
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestNewMcpSessionManagerImpl(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
require.NotNil(t, m)
|
|
require.NotNil(t, m.sessions)
|
|
assert.Empty(t, m.sessions)
|
|
}
|
|
|
|
func TestSessionManager_CreateAndGet(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
id, err := m.CreateSession("http://backend.example/mcp")
|
|
require.NoError(t, err)
|
|
assert.True(t, strings.HasPrefix(id, "mcp-session-"))
|
|
|
|
session, ok := m.GetSession(id)
|
|
require.True(t, ok)
|
|
assert.Equal(t, id, session.ID)
|
|
assert.Equal(t, "http://backend.example/mcp", session.BackendURL)
|
|
assert.False(t, session.CreatedAt.IsZero())
|
|
}
|
|
|
|
func TestSessionManager_GetSessionUpdatesLastUsed(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
id, err := m.CreateSession("http://b")
|
|
require.NoError(t, err)
|
|
|
|
// Force a measurable gap so LastUsed changes monotonically.
|
|
original := m.sessions[id].LastUsed
|
|
time.Sleep(2 * time.Millisecond)
|
|
|
|
s, ok := m.GetSession(id)
|
|
require.True(t, ok)
|
|
assert.True(t, s.LastUsed.After(original), "GetSession should refresh LastUsed")
|
|
}
|
|
|
|
func TestSessionManager_GetUnknownSession(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
s, ok := m.GetSession("missing")
|
|
assert.False(t, ok)
|
|
assert.Nil(t, s)
|
|
}
|
|
|
|
func TestSessionManager_CleanupSession_Existing(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
id, _ := m.CreateSession("http://b")
|
|
m.CleanupSession(id)
|
|
_, ok := m.GetSession(id)
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestSessionManager_CleanupSession_NonExistent(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
// Must not panic on unknown id.
|
|
m.CleanupSession("never-existed")
|
|
assert.Empty(t, m.sessions)
|
|
}
|
|
|
|
func TestSessionManager_CleanupExpiredSessions(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
fresh, _ := m.CreateSession("fresh")
|
|
stale, _ := m.CreateSession("stale")
|
|
|
|
// Backdate the stale session.
|
|
m.sessions[stale].LastUsed = time.Now().Add(-10 * time.Minute)
|
|
|
|
m.CleanupExpiredSessions(1 * time.Minute)
|
|
|
|
_, freshOk := m.sessions[fresh]
|
|
_, staleOk := m.sessions[stale]
|
|
assert.True(t, freshOk, "fresh session must remain")
|
|
assert.False(t, staleOk, "stale session must be removed")
|
|
}
|
|
|
|
func TestSessionManager_CleanupExpiredSessions_EmptyMap(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
// Must not panic on empty manager.
|
|
m.CleanupExpiredSessions(1 * time.Second)
|
|
assert.Empty(t, m.sessions)
|
|
}
|
|
|
|
func TestSessionManager_CreateSessionsAreUnique(t *testing.T) {
|
|
m := NewMcpSessionManagerImpl()
|
|
id1, _ := m.CreateSession("http://b")
|
|
// Guarantee a different nanosecond timestamp.
|
|
time.Sleep(1 * time.Millisecond)
|
|
id2, _ := m.CreateSession("http://b")
|
|
assert.NotEqual(t, id1, id2, "session IDs should be unique")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// ensureHeader
|
|
// -----------------------------------------------------------------------------
|
|
|
|
func TestEnsureHeader_AddsWhenMissing(t *testing.T) {
|
|
headers := [][2]string{{"X-Other", "v"}}
|
|
ensureHeader(&headers, "X-New", "value")
|
|
require.Len(t, headers, 2)
|
|
assert.Equal(t, [2]string{"X-New", "value"}, headers[1])
|
|
}
|
|
|
|
func TestEnsureHeader_ReplacesCaseInsensitively(t *testing.T) {
|
|
headers := [][2]string{{"content-type", "text/plain"}}
|
|
ensureHeader(&headers, "Content-Type", "application/json")
|
|
require.Len(t, headers, 1)
|
|
// Replace path rewrites the original casing too.
|
|
assert.Equal(t, "Content-Type", headers[0][0])
|
|
assert.Equal(t, "application/json", headers[0][1])
|
|
}
|
|
|
|
func TestEnsureHeader_NoDuplicateOnRepeatedCalls(t *testing.T) {
|
|
headers := [][2]string{}
|
|
ensureHeader(&headers, "X-K", "1")
|
|
ensureHeader(&headers, "X-K", "2")
|
|
require.Len(t, headers, 1)
|
|
assert.Equal(t, "2", headers[0][1])
|
|
}
|