feat: add send file preview tool
This commit is contained in:
187
internal/tools/isphere_send_file_test.go
Normal file
187
internal/tools/isphere_send_file_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestISphereSendFilePreviewComputesHashAndSize(t *testing.T) {
|
||||
allowedDir := t.TempDir()
|
||||
filePath := filepath.Join(allowedDir, "hello.txt")
|
||||
if err := os.WriteFile(filePath, []byte("hello"), 0o600); err != nil {
|
||||
t.Fatalf("write fixture file: %v", err)
|
||||
}
|
||||
t.Setenv(EnvSendFileAllowedDir, allowedDir)
|
||||
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendFileTool(server)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendFile,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"file_path": filePath,
|
||||
"file_sha256": sha256HexForSendMessageTest("hello"),
|
||||
"idempotency_key": "file-preview-idem-1",
|
||||
"execution_mode": "preview",
|
||||
"preview": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s: %v", ToolNameSendFile, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("preview should be structured success, got error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
FileSHA256 string `json:"file_sha256"`
|
||||
FileSizeBytes int64 `json:"file_size_bytes"`
|
||||
TargetRef string `json:"target_ref"`
|
||||
ProductionSendEnabled bool `json:"production_send_enabled"`
|
||||
RealSendAttempted bool `json:"real_send_attempted"`
|
||||
SideEffectFlags map[string]any `json:"side_effect_flags"`
|
||||
}
|
||||
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode send-file preview payload %s: %v", payload, err)
|
||||
}
|
||||
if !decoded.OK || decoded.SendStatus != "planned" {
|
||||
t.Fatalf("preview status mismatch: %s", payload)
|
||||
}
|
||||
if decoded.FileSHA256 != sha256HexForSendMessageTest("hello") || decoded.FileSizeBytes != 5 {
|
||||
t.Fatalf("file metadata mismatch: %s", payload)
|
||||
}
|
||||
if decoded.TargetRef != "contact:alice@imopenfire1-lanzhou" {
|
||||
t.Fatalf("target_ref = %q, want contact ref", decoded.TargetRef)
|
||||
}
|
||||
if decoded.ProductionSendEnabled || decoded.RealSendAttempted {
|
||||
t.Fatalf("preview must not enable or attempt real send: %s", payload)
|
||||
}
|
||||
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["clicked_ui"] != false {
|
||||
t.Fatalf("side-effect flags mismatch: %#v", decoded.SideEffectFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendFileRejectsPathOutsideAllowedDir(t *testing.T) {
|
||||
allowedDir := t.TempDir()
|
||||
outsideDir := t.TempDir()
|
||||
filePath := filepath.Join(outsideDir, "outside.txt")
|
||||
if err := os.WriteFile(filePath, []byte("outside"), 0o600); err != nil {
|
||||
t.Fatalf("write outside fixture file: %v", err)
|
||||
}
|
||||
t.Setenv(EnvSendFileAllowedDir, allowedDir)
|
||||
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendFileTool(server)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendFile,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "group",
|
||||
"target_id": "project-room@conference.imopenfire1-lanzhou",
|
||||
"file_path": filePath,
|
||||
"file_sha256": sha256HexForSendMessageTest("outside"),
|
||||
"idempotency_key": "file-outside-idem-1",
|
||||
"execution_mode": "preview",
|
||||
"preview": true,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s: %v", ToolNameSendFile, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("outside path should be structured blocked metadata, got error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
TargetRef string `json:"target_ref"`
|
||||
SideEffectFlags map[string]any `json:"side_effect_flags"`
|
||||
}
|
||||
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode outside-path payload %s: %v", payload, err)
|
||||
}
|
||||
if decoded.OK || decoded.SendStatus != "blocked" {
|
||||
t.Fatalf("outside path should be blocked: %s", payload)
|
||||
}
|
||||
if !strings.Contains(decoded.BlockedReason, EnvSendFileAllowedDir) {
|
||||
t.Fatalf("blocked reason should mention allowed-dir env: %q", decoded.BlockedReason)
|
||||
}
|
||||
if decoded.TargetRef != "group:project-room@conference.imopenfire1-lanzhou" {
|
||||
t.Fatalf("target_ref = %q, want group ref", decoded.TargetRef)
|
||||
}
|
||||
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["typed_text"] != false {
|
||||
t.Fatalf("blocked path should have no side effects: %#v", decoded.SideEffectFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendFileProductionBlockedWithoutSideEffects(t *testing.T) {
|
||||
allowedDir := t.TempDir()
|
||||
filePath := filepath.Join(allowedDir, "blocked.txt")
|
||||
if err := os.WriteFile(filePath, []byte("blocked"), 0o600); err != nil {
|
||||
t.Fatalf("write blocked fixture file: %v", err)
|
||||
}
|
||||
t.Setenv(EnvSendFileAllowedDir, allowedDir)
|
||||
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendFileTool(server)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendFile,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"file_path": filePath,
|
||||
"file_sha256": sha256HexForSendMessageTest("blocked"),
|
||||
"idempotency_key": "file-production-idem-1",
|
||||
"execution_mode": "production",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s production: %v", ToolNameSendFile, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("production should be structured blocked metadata, got error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
ProductionSendEnabled bool `json:"production_send_enabled"`
|
||||
RealSendAttempted bool `json:"real_send_attempted"`
|
||||
SideEffectFlags map[string]any `json:"side_effect_flags"`
|
||||
}
|
||||
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode production-blocked payload %s: %v", payload, err)
|
||||
}
|
||||
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" {
|
||||
t.Fatalf("production should be blocked with reason: %s", payload)
|
||||
}
|
||||
if decoded.ProductionSendEnabled || decoded.RealSendAttempted {
|
||||
t.Fatalf("production must not be enabled or attempted: %s", payload)
|
||||
}
|
||||
if decoded.SideEffectFlags["sent_file"] != false || decoded.SideEffectFlags["uploaded_file"] != false || decoded.SideEffectFlags["captured_network"] != false {
|
||||
t.Fatalf("production blocked should have no side effects: %#v", decoded.SideEffectFlags)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user