Files
isphere-ai-bridge/internal/mcpserver/server_test.go
2026-07-10 21:22:39 +08:00

379 lines
12 KiB
Go

package mcpserver
import (
"context"
"crypto/cipher"
"crypto/des"
"encoding/base64"
"encoding/json"
"os"
"reflect"
"sort"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"isphere-ai-bridge/internal/msglib"
)
func TestNewServerConstructsMCPServer(t *testing.T) {
var server *mcp.Server = NewServer()
if server == nil {
t.Fatal("NewServer() returned nil")
}
}
func TestNewServerRegistersTenToolsWithoutCallingHelper(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
serverTransport, clientTransport := mcp.NewInMemoryTransports()
server := NewServer()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx, serverTransport)
}()
client := mcp.NewClient(&mcp.Implementation{Name: "mcpserver-test-client", Version: "0.0.0"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
cancel()
t.Fatalf("connect client: %v", err)
}
defer func() {
_ = session.Close()
cancel()
select {
case <-errCh:
case <-time.After(2 * time.Second):
t.Fatalf("server did not stop")
}
}()
result, err := session.ListTools(context.Background(), &mcp.ListToolsParams{})
if err != nil {
t.Fatalf("list tools: %v", err)
}
gotNames := make([]string, 0, len(result.Tools))
for _, tool := range result.Tools {
gotNames = append(gotNames, tool.Name)
}
sort.Strings(gotNames)
wantNames := []string{"isphere_receive_files", "isphere_receive_messages", "isphere_search_contacts", "isphere_search_groups", "isphere_send_file", "isphere_send_message", "win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"}
if !reflect.DeepEqual(gotNames, wantNames) {
t.Fatalf("server tool names = %#v, want %#v", gotNames, wantNames)
}
}
func TestNewServerFromEnvRejectsPartialMsgLibConfig(t *testing.T) {
clearMsgLibEnvForServerTest(t)
t.Setenv(EnvMsgLibSidecarExeKey, `C:\tools\MsgLibReadSidecar.exe`)
_, err := NewServerFromEnv()
if err == nil {
t.Fatal("NewServerFromEnv returned nil error for partial MsgLib config")
}
if !strings.Contains(err.Error(), EnvMsgLibSQLiteDLLKey) || !strings.Contains(err.Error(), EnvMsgLibDBKey) {
t.Fatalf("error = %v, want missing MsgLib env context", err)
}
}
func TestNewServerFromEnvWiresExplicitMsgLibReceiveSource(t *testing.T) {
clearMsgLibEnvForServerTest(t)
fake := &fakeMsgLibClientForServerTest{
listResult: msglib.ListMessagesResult{
ReadOnly: true,
MessageBodyValuesReturned: true,
RawRowsReturned: false,
FilePathsReturned: false,
SourceTables: []string{"tblPersonMsg"},
Messages: []msglib.ListMessage{{
MessageID: "db-env-msg-1",
ID: "db-env-msg-1",
ConversationID: "db-env-conversation",
ConversationType: "direct",
SenderID: "db-env-sender",
ReceiverID: "db-env-receiver",
ContentText: "db env body",
Timestamp: "1783423809000",
Source: "local_readonly",
RawRef: "msglib:tblPersonMsg",
}},
},
}
oldFactory := newMsgLibClient
newMsgLibClient = func(msglib.Config) msgLibClient {
return fake
}
defer func() { newMsgLibClient = oldFactory }()
t.Setenv(EnvMsgLibSidecarExeKey, `C:\fixture\MsgLibReadSidecar.exe`)
t.Setenv(EnvMsgLibSQLiteDLLKey, `C:\fixture\System.Data.SQLite.dll`)
t.Setenv(EnvMsgLibDBKey, `C:\fixture\MsgLib.db`)
server, err := NewServerFromEnv()
if err != nil {
t.Fatalf("NewServerFromEnv returned error: %v", err)
}
session, cleanup := connectServerTestSession(t, server)
defer cleanup()
defaultResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_receive_messages",
Arguments: map[string]any{"limit": 5},
})
if err != nil {
t.Fatalf("default receive call: %v", err)
}
if defaultResult.IsError {
t.Fatalf("default receive returned error: %+v", defaultResult)
}
if len(fake.listCalls) != 0 {
t.Fatalf("default receive called MsgLib ListMessages: %+v", fake.listCalls)
}
dbResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_receive_messages",
Arguments: map[string]any{"source_preference": "msglib_readonly", "limit": 5},
})
if err != nil {
t.Fatalf("msglib receive call: %v", err)
}
if dbResult.IsError {
t.Fatalf("msglib receive returned error: %+v", dbResult)
}
payload, err := json.Marshal(dbResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
var decoded struct {
Messages []struct {
ID string `json:"id"`
RawRef string `json:"raw_ref"`
} `json:"messages"`
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 || decoded.Messages[0].ID != "db-env-msg-1" || decoded.Messages[0].RawRef != "msglib:tblPersonMsg" {
t.Fatalf("messages = %+v, want explicit MsgLib result", decoded.Messages)
}
if len(fake.listCalls) != 1 || !fake.listCalls[0].IncludeBody || !fake.listCalls[0].IncludeAttachmentMetadata {
t.Fatalf("ListMessages calls = %+v", fake.listCalls)
}
}
func TestNewServerFromEnvUsesPacketLogFile(t *testing.T) {
clearMsgLibEnvForServerTest(t)
plaintext := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:30:07
<message id="msg-env-1" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>redacted from env</body>
<received xmlns="urn:xmpp:receipts" id="receipt-env-1" type="1" stamp="" />
<subject>ENV_SOURCE</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
<readed />
</message>`
filePath := writeEncryptedPacketLogFileForServerTest(t, plaintext)
t.Setenv("ISPHERE_PACKET_LOG_FILE", filePath)
server, err := NewServerFromEnv()
if err != nil {
t.Fatalf("NewServerFromEnv returned error: %v", err)
}
session, cleanup := connectServerTestSession(t, server)
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_receive_messages",
Arguments: map[string]any{"limit": 10},
})
if err != nil {
t.Fatalf("call isphere_receive_messages: %v", err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Messages []struct {
ID string `json:"id"`
Text string `json:"text"`
Subject string `json:"subject"`
ConversationID string `json:"conversation_id"`
SenderID string `json:"sender_id"`
ReceiverID string `json:"receiver_id"`
Read bool `json:"read"`
} `json:"messages"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 {
t.Fatalf("message count = %d, want 1", len(decoded.Messages))
}
msg := decoded.Messages[0]
if msg.ID != "msg-env-1" || msg.Text != "redacted from env" || msg.Subject != "ENV_SOURCE" {
t.Fatalf("unexpected message: %+v", msg)
}
if msg.ConversationID != "sender@imopenfire1-lanzhou|receiver@imopenfire1-lanzhou" {
t.Fatalf("conversation id = %q", msg.ConversationID)
}
if msg.SenderID != "sender@imopenfire1-lanzhou" || msg.ReceiverID != "receiver@imopenfire1-lanzhou" || !msg.Read {
t.Fatalf("metadata = %+v", msg)
}
}
func TestNewServerFromEnvUsesPacketLogDirectory(t *testing.T) {
clearMsgLibEnvForServerTest(t)
plaintext := `--------------------------------------------------------------------------------------------------------------------------------------------
2026/7/7 15:30:07
<message id="msg-dir-1" from="sender@imopenfire1-lanzhou/imp_pc_4.1.2.6842" to="receiver@imopenfire1-lanzhou" type="chat">
<body>redacted from dir</body>
<received xmlns="urn:xmpp:receipts" id="receipt-dir-1" type="1" stamp="" />
<subject>DIR_SOURCE</subject>
<isphere xmlns="isphere.im" type="1001" sendtime="1783423807000" version="1" />
<readed />
</message>`
dirPath := writeEncryptedPacketLogDirectoryForServerTest(t, plaintext)
t.Setenv("ISPHERE_PACKET_LOG_DIR", dirPath)
server, err := NewServerFromEnv()
if err != nil {
t.Fatalf("NewServerFromEnv returned error: %v", err)
}
session, cleanup := connectServerTestSession(t, server)
defer cleanup()
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "isphere_receive_messages",
Arguments: map[string]any{"limit": 10},
})
if err != nil {
t.Fatalf("call isphere_receive_messages: %v", err)
}
if callResult.IsError {
t.Fatalf("call result is error: %+v", callResult)
}
var decoded struct {
Messages []struct {
ID string `json:"id"`
Text string `json:"text"`
Subject string `json:"subject"`
} `json:"messages"`
}
payload, err := json.Marshal(callResult.StructuredContent)
if err != nil {
t.Fatalf("marshal structured content: %v", err)
}
if err := json.Unmarshal(payload, &decoded); err != nil {
t.Fatalf("decode structured content %s: %v", payload, err)
}
if len(decoded.Messages) != 1 {
t.Fatalf("message count = %d, want 1", len(decoded.Messages))
}
msg := decoded.Messages[0]
if msg.ID != "msg-dir-1" || msg.Text != "redacted from dir" || msg.Subject != "DIR_SOURCE" {
t.Fatalf("unexpected message: %+v", msg)
}
}
type fakeMsgLibClientForServerTest struct {
displayCalls []msglib.DisplayEntitiesOptions
listCalls []msglib.ListMessagesOptions
listResult msglib.ListMessagesResult
}
func (f *fakeMsgLibClientForServerTest) DisplayEntities(_ context.Context, opts msglib.DisplayEntitiesOptions) ([]msglib.DisplayEntity, error) {
f.displayCalls = append(f.displayCalls, opts)
return nil, nil
}
func (f *fakeMsgLibClientForServerTest) ListMessages(_ context.Context, opts msglib.ListMessagesOptions) (msglib.ListMessagesResult, error) {
f.listCalls = append(f.listCalls, opts)
return f.listResult, nil
}
func clearMsgLibEnvForServerTest(t *testing.T) {
t.Helper()
t.Setenv(EnvMsgLibSidecarExeKey, "")
t.Setenv(EnvMsgLibSQLiteDLLKey, "")
t.Setenv(EnvMsgLibDBKey, "")
t.Setenv("ISPHERE_MSGLIB_PASSWORD", "")
}
func writeEncryptedPacketLogDirectoryForServerTest(t *testing.T, plaintext string) string {
t.Helper()
dir := t.TempDir()
line := encryptPacketLogLineForServerTest(t, plaintext)
if err := os.WriteFile(dir+"\\packet-reader-2.txt", []byte("\n"+line+"\n"), 0o600); err != nil {
t.Fatalf("write encrypted packet log directory fixture: %v", err)
}
return dir
}
func connectServerTestSession(t *testing.T, server *mcp.Server) (*mcp.ClientSession, func()) {
t.Helper()
ctx, cancel := context.WithCancel(context.Background())
serverTransport, clientTransport := mcp.NewInMemoryTransports()
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx, serverTransport)
}()
client := mcp.NewClient(&mcp.Implementation{Name: "mcpserver-env-test-client", Version: "0.0.0"}, nil)
session, err := client.Connect(ctx, clientTransport, nil)
if err != nil {
cancel()
t.Fatalf("connect client: %v", err)
}
cleanup := func() {
_ = session.Close()
cancel()
select {
case <-errCh:
case <-time.After(2 * time.Second):
t.Fatalf("server did not stop")
}
}
return session, cleanup
}
func writeEncryptedPacketLogFileForServerTest(t *testing.T, plaintext string) string {
t.Helper()
path := t.TempDir() + "\\packet-reader.log"
line := encryptPacketLogLineForServerTest(t, plaintext)
if err := os.WriteFile(path, []byte("\n"+line+"\n"), 0o600); err != nil {
t.Fatalf("write encrypted packet log file: %v", err)
}
return path
}
func encryptPacketLogLineForServerTest(t *testing.T, plaintext string) string {
t.Helper()
block, err := des.NewCipher([]byte("hyhccdtm"))
if err != nil {
t.Fatalf("NewCipher: %v", err)
}
plain := padPacketLogLineForServerTest([]byte(plaintext), des.BlockSize)
ciphertext := make([]byte, len(plain))
iv := []byte{0x12, 0x34, 0x56, 0x78, 0x90, 0xAB, 0xCD, 0xEF}
cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plain)
return base64.StdEncoding.EncodeToString(ciphertext)
}
func padPacketLogLineForServerTest(data []byte, blockSize int) []byte {
pad := blockSize - len(data)%blockSize
out := append([]byte(nil), data...)
for i := 0; i < pad; i++ {
out = append(out, byte(pad))
}
return out
}