mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50:12 +00:00
291 lines
11 KiB
Go
291 lines
11 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
type testJSONRPCRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID uint64 `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
|
|
type testToolCallParams struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type fileReadArguments struct {
|
|
ServerID uint64 `json:"server_id"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type fileReadResult struct {
|
|
Size int64 `json:"size"`
|
|
SHA256 string `json:"sha256"`
|
|
}
|
|
|
|
func TestClient_MCPStructuredContent(t *testing.T) {
|
|
requestIDs := make(chan uint64, 2)
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
require.Equal(t, "/mcp", request.URL.Path)
|
|
require.Equal(t, "Bearer mcp-token", request.Header.Get("Authorization"))
|
|
require.NotEmpty(t, request.Header.Get("Origin"))
|
|
|
|
var rpcRequest testJSONRPCRequest
|
|
require.NoError(t, json.NewDecoder(request.Body).Decode(&rpcRequest))
|
|
require.Equal(t, "2.0", rpcRequest.JSONRPC)
|
|
require.Equal(t, "tools/call", rpcRequest.Method)
|
|
var params testToolCallParams
|
|
require.NoError(t, json.Unmarshal(rpcRequest.Params, ¶ms))
|
|
require.Equal(t, "fs.read", params.Name)
|
|
requestIDs <- rpcRequest.ID
|
|
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"jsonrpc":"2.0","id":` + jsonNumber(rpcRequest.ID) + `,"result":{"content":[{"type":"text","text":"ok"}],"structuredContent":{"size":7,"sha256":"abc123"}}}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
client := newTestClient(t, Config{
|
|
BaseURL: server.URL,
|
|
BearerToken: "mcp-token",
|
|
Origin: server.URL,
|
|
RequestTimeout: time.Second,
|
|
MaxResponseBytes: 1024,
|
|
})
|
|
call := ToolCall[fileReadArguments]{Name: "fs.read", Arguments: fileReadArguments{ServerID: 7, Path: "/tmp/report"}}
|
|
first, err := CallTool[fileReadArguments, fileReadResult](context.Background(), client, call)
|
|
require.NoError(t, err)
|
|
require.Equal(t, int64(7), first.StructuredContent.Size)
|
|
require.Equal(t, "abc123", first.StructuredContent.SHA256)
|
|
|
|
_, err = CallTool[fileReadArguments, fileReadResult](context.Background(), client, call)
|
|
require.NoError(t, err)
|
|
require.Equal(t, uint64(1), <-requestIDs)
|
|
require.Equal(t, uint64(2), <-requestIDs)
|
|
}
|
|
|
|
func TestClient_MCPUnauthorized(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
writer.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = writer.Write([]byte(`{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":"unauthorized"}}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
client := newTestClient(t, Config{
|
|
BaseURL: server.URL,
|
|
BearerToken: "invalid-token",
|
|
Origin: server.URL,
|
|
RequestTimeout: time.Second,
|
|
MaxResponseBytes: 1024,
|
|
})
|
|
_, err := CallTool[fileReadArguments, fileReadResult](context.Background(), client, ToolCall[fileReadArguments]{
|
|
Name: "fs.read",
|
|
Arguments: fileReadArguments{ServerID: 7, Path: "/tmp/report"},
|
|
})
|
|
require.Error(t, err)
|
|
require.True(t, errors.Is(err, ErrUnauthorized))
|
|
}
|
|
|
|
func TestClient_MCPClassifiesNonJSONUnauthorized(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
writer.WriteHeader(http.StatusUnauthorized)
|
|
_, _ = writer.Write([]byte("unauthorized"))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
client := newTestClient(t, Config{BaseURL: server.URL, RequestTimeout: time.Second, MaxResponseBytes: 1024})
|
|
_, err := client.Initialize(context.Background())
|
|
require.ErrorIs(t, err, ErrUnauthorized)
|
|
}
|
|
|
|
func TestClient_MCPToolSemanticFailure(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
var rpcRequest testJSONRPCRequest
|
|
require.NoError(t, json.NewDecoder(request.Body).Decode(&rpcRequest))
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"jsonrpc":"2.0","id":` + jsonNumber(rpcRequest.ID) + `,"result":{"content":[{"type":"text","text":"agent offline"}],"isError":true}}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
client := newTestClient(t, Config{BaseURL: server.URL, RequestTimeout: time.Second, MaxResponseBytes: 1024})
|
|
_, err := CallTool[fileReadArguments, fileReadResult](context.Background(), client, ToolCall[fileReadArguments]{
|
|
Name: "fs.read",
|
|
Arguments: fileReadArguments{ServerID: 7, Path: "/tmp/report"},
|
|
})
|
|
require.ErrorIs(t, err, ErrToolFailure)
|
|
}
|
|
|
|
func TestClient_MCPToolFailurePreservesStructuredContent(t *testing.T) {
|
|
// Given
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
var rpcRequest testJSONRPCRequest
|
|
require.NoError(t, json.NewDecoder(request.Body).Decode(&rpcRequest))
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"jsonrpc":"2.0","id":` + jsonNumber(rpcRequest.ID) + `,"result":{"content":[{"type":"text","text":"command not found"}],"structuredContent":{"exit_code":127,"error":"command or working directory not found"},"isError":true}}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
client := newTestClient(t, Config{BaseURL: server.URL, RequestTimeout: time.Second, MaxResponseBytes: 1024})
|
|
_, err := CallTool[fileReadArguments, fileReadResult](context.Background(), client, ToolCall[fileReadArguments]{Name: "server.exec", Arguments: fileReadArguments{ServerID: 7}})
|
|
|
|
// Then
|
|
var toolFailure *ToolFailure
|
|
require.ErrorAs(t, err, &toolFailure)
|
|
require.ErrorIs(t, err, ErrToolFailure)
|
|
require.Equal(t, "command not found", toolFailure.Message)
|
|
require.JSONEq(t, `{"exit_code":127,"error":"command or working directory not found"}`, string(toolFailure.StructuredContent))
|
|
}
|
|
|
|
func TestClient_MCPArbitraryTransportErrorIsNotToolFailure(t *testing.T) {
|
|
// Given
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
_, _ = io.Copy(io.Discard, request.Body)
|
|
<-request.Context().Done()
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
client := newTestClient(t, Config{BaseURL: server.URL, RequestTimeout: 20 * time.Millisecond, MaxResponseBytes: 1024})
|
|
|
|
// When
|
|
_, err := CallTool[fileReadArguments, fileReadResult](context.Background(), client, ToolCall[fileReadArguments]{Name: "server.exec", Arguments: fileReadArguments{ServerID: 7}})
|
|
|
|
// Then
|
|
var toolFailure *ToolFailure
|
|
require.Error(t, err)
|
|
require.NotErrorAs(t, err, &toolFailure)
|
|
require.NotErrorIs(t, err, ErrToolFailure)
|
|
}
|
|
|
|
func TestClient_MCPMalformedStructuredToolFailureIsTyped(t *testing.T) {
|
|
// Given
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
var rpcRequest testJSONRPCRequest
|
|
require.NoError(t, json.NewDecoder(request.Body).Decode(&rpcRequest))
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"jsonrpc":"2.0","id":` + jsonNumber(rpcRequest.ID) + `,"result":{"content":[{"type":"text","text":"invalid command"}],"structuredContent":"not-an-object","isError":true}}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
client := newTestClient(t, Config{BaseURL: server.URL, RequestTimeout: time.Second, MaxResponseBytes: 1024})
|
|
|
|
// When
|
|
_, err := CallTool[fileReadArguments, fileReadResult](context.Background(), client, ToolCall[fileReadArguments]{Name: "server.exec", Arguments: fileReadArguments{ServerID: 7}})
|
|
|
|
// Then
|
|
var toolFailure *ToolFailure
|
|
require.ErrorAs(t, err, &toolFailure)
|
|
require.ErrorIs(t, err, ErrToolFailure)
|
|
require.JSONEq(t, `"not-an-object"`, string(toolFailure.StructuredContent))
|
|
}
|
|
|
|
func TestClient_MCPRejectsOversize(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"padding":"` + string(make([]byte, 256)) + `"}}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
client := newTestClient(t, Config{BaseURL: server.URL, RequestTimeout: time.Second, MaxResponseBytes: 64})
|
|
_, err := client.Initialize(context.Background())
|
|
require.ErrorIs(t, err, ErrResponseTooLarge)
|
|
}
|
|
|
|
func TestClient_MCPDeadline(t *testing.T) {
|
|
// The handler must enter before the client deadline: scheduling the handler
|
|
// against a tiny timeout can make this test miss a real MCP request.
|
|
requestBodyDrained := make(chan struct{})
|
|
requestCancelled := make(chan struct{})
|
|
releaseHandler := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) {
|
|
if _, err := io.Copy(io.Discard, request.Body); err != nil {
|
|
return
|
|
}
|
|
close(requestBodyDrained)
|
|
select {
|
|
case <-request.Context().Done():
|
|
close(requestCancelled)
|
|
case <-releaseHandler:
|
|
}
|
|
}))
|
|
t.Cleanup(func() {
|
|
close(releaseHandler)
|
|
server.Close()
|
|
})
|
|
|
|
client := newTestClient(t, Config{BaseURL: server.URL, RequestTimeout: time.Second, MaxResponseBytes: 1024})
|
|
result := make(chan error, 1)
|
|
go func() {
|
|
_, err := client.Initialize(context.Background())
|
|
result <- err
|
|
}()
|
|
waitContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
select {
|
|
case <-requestBodyDrained:
|
|
case <-waitContext.Done():
|
|
t.Fatal("server did not receive MCP request")
|
|
}
|
|
var err error
|
|
select {
|
|
case err = <-result:
|
|
case <-waitContext.Done():
|
|
t.Fatal("MCP request did not reach its deadline")
|
|
}
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
select {
|
|
case <-requestCancelled:
|
|
case <-waitContext.Done():
|
|
t.Fatal("server request context was not cancelled")
|
|
}
|
|
}
|
|
|
|
func TestClient_MCPRejectsResultAndErrorTogether(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
|
var rpcRequest testJSONRPCRequest
|
|
require.NoError(t, json.NewDecoder(request.Body).Decode(&rpcRequest))
|
|
writer.Header().Set("Content-Type", "application/json")
|
|
_, _ = writer.Write([]byte(`{"jsonrpc":"2.0","id":` + jsonNumber(rpcRequest.ID) + `,"result":{},"error":{"code":-32603,"message":"invalid"}}`))
|
|
}))
|
|
t.Cleanup(server.Close)
|
|
|
|
client := newTestClient(t, Config{BaseURL: server.URL, RequestTimeout: time.Second, MaxResponseBytes: 1024})
|
|
_, err := client.Initialize(context.Background())
|
|
require.ErrorIs(t, err, ErrJSONRPC)
|
|
}
|
|
|
|
func TestClient_MCPRejectsRedirectWithoutForwardingAuthorization(t *testing.T) {
|
|
var redirected atomic.Int32
|
|
foreignServer := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
|
redirected.Add(1)
|
|
}))
|
|
t.Cleanup(foreignServer.Close)
|
|
|
|
dashboardServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
|
http.Redirect(writer, &http.Request{}, foreignServer.URL+"/capture", http.StatusTemporaryRedirect)
|
|
}))
|
|
t.Cleanup(dashboardServer.Close)
|
|
|
|
client := newTestClient(t, Config{BaseURL: dashboardServer.URL, BearerToken: "mcp-token", RequestTimeout: time.Second, MaxResponseBytes: 1024})
|
|
_, err := client.Initialize(context.Background())
|
|
require.ErrorIs(t, err, ErrRedirect)
|
|
require.Zero(t, redirected.Load())
|
|
}
|
|
|
|
func jsonNumber(value uint64) string {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(encoded)
|
|
}
|