feat: add send message preview contract
This commit is contained in:
180
internal/tools/isphere_send_message_test.go
Normal file
180
internal/tools/isphere_send_message_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
||||
func TestISphereSendMessagePreviewPlansAndAuditsWithoutRawContent(t *testing.T) {
|
||||
audit := &fakeSendMessageAuditSink{}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, audit)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
content := "hello preview"
|
||||
contentHash := sha256HexForSendMessageTest(content)
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendMessage,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"content_text": content,
|
||||
"content_sha256": contentHash,
|
||||
"idempotency_key": "idem-preview-1",
|
||||
"execution_mode": "preview",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s: %v", ToolNameSendMessage, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("call result is error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
ExecutionMode string `json:"execution_mode"`
|
||||
Connector string `json:"connector"`
|
||||
ConnectorStage string `json:"connector_stage"`
|
||||
Target map[string]any `json:"target"`
|
||||
Content map[string]any `json:"content"`
|
||||
SideEffects map[string]any `json:"side_effects"`
|
||||
Audit map[string]any `json:"audit"`
|
||||
}
|
||||
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 !decoded.OK || decoded.SendStatus != "planned" || decoded.ExecutionMode != "preview" {
|
||||
t.Fatalf("unexpected preview status: %s", payload)
|
||||
}
|
||||
if decoded.Connector != "implatform-sidecar" || decoded.ConnectorStage != "preview" {
|
||||
t.Fatalf("unexpected connector fields: %s", payload)
|
||||
}
|
||||
if decoded.Target["target_type"] != "direct" || decoded.Target["target_id"] != "alice@imopenfire1-lanzhou" || decoded.Target["target_ref"] != "contact:alice@imopenfire1-lanzhou" {
|
||||
t.Fatalf("unexpected target: %#v", decoded.Target)
|
||||
}
|
||||
if decoded.Content["content_sha256"] != contentHash || decoded.Content["content_length"] != float64(len(content)) {
|
||||
t.Fatalf("unexpected content metadata: %#v", decoded.Content)
|
||||
}
|
||||
if _, hasRawBody := decoded.Content["content_text"]; hasRawBody {
|
||||
t.Fatalf("preview response leaked raw content body: %s", payload)
|
||||
}
|
||||
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["clicked_ui"] != false || decoded.SideEffects["captured_network"] != false {
|
||||
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
|
||||
}
|
||||
if decoded.Audit["result"] != "planned" || decoded.Audit["tool"] != ToolNameSendMessage {
|
||||
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
|
||||
}
|
||||
if len(audit.events) != 1 {
|
||||
t.Fatalf("audit events = %+v, want one", audit.events)
|
||||
}
|
||||
event := audit.events[0]
|
||||
if event.ContentSHA256 != contentHash || event.TargetRef != "contact:alice@imopenfire1-lanzhou" || event.Result != "planned" {
|
||||
t.Fatalf("unexpected audit event: %+v", event)
|
||||
}
|
||||
if event.ContentText != "" || event.IdempotencyKey != "" {
|
||||
t.Fatalf("audit event leaked raw content or idempotency key: %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendMessageProductionIsBlockedWithoutSideEffects(t *testing.T) {
|
||||
audit := &fakeSendMessageAuditSink{}
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, audit)
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
contentHash := sha256HexForSendMessageTest("blocked send")
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendMessage,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "group",
|
||||
"target_id": "project-room@conference.imopenfire1-lanzhou",
|
||||
"content_text": "blocked send",
|
||||
"content_sha256": contentHash,
|
||||
"idempotency_key": "idem-production-1",
|
||||
"execution_mode": "production",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("call %s production: %v", ToolNameSendMessage, err)
|
||||
}
|
||||
if callResult.IsError {
|
||||
t.Fatalf("production blocked result should be structured, got error: %+v", callResult)
|
||||
}
|
||||
|
||||
var decoded struct {
|
||||
OK bool `json:"ok"`
|
||||
SendStatus string `json:"send_status"`
|
||||
BlockedReason string `json:"blocked_reason"`
|
||||
Target map[string]any `json:"target"`
|
||||
SideEffects map[string]any `json:"side_effects"`
|
||||
Audit map[string]any `json:"audit"`
|
||||
}
|
||||
payload, _ := json.Marshal(callResult.StructuredContent)
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
t.Fatalf("decode structured content %s: %v", payload, err)
|
||||
}
|
||||
if decoded.OK || decoded.SendStatus != "blocked" || decoded.BlockedReason == "" {
|
||||
t.Fatalf("production was not clearly blocked: %s", payload)
|
||||
}
|
||||
if decoded.Target["target_ref"] != "group:project-room@conference.imopenfire1-lanzhou" {
|
||||
t.Fatalf("unexpected target: %#v", decoded.Target)
|
||||
}
|
||||
if decoded.SideEffects["sent_message"] != false || decoded.SideEffects["uploaded_file"] != false || decoded.SideEffects["typed_text"] != false {
|
||||
t.Fatalf("side effect flags not all false: %#v", decoded.SideEffects)
|
||||
}
|
||||
if decoded.Audit["result"] != "blocked" {
|
||||
t.Fatalf("unexpected audit response: %#v", decoded.Audit)
|
||||
}
|
||||
if len(audit.events) != 1 || audit.events[0].Result != "blocked" {
|
||||
t.Fatalf("audit events = %+v, want one blocked event", audit.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestISphereSendMessageRejectsContentHashMismatch(t *testing.T) {
|
||||
session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) {
|
||||
RegisterISphereSendMessageTool(server, &fakeSendMessageAuditSink{})
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
callResult, err := session.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: ToolNameSendMessage,
|
||||
Arguments: map[string]any{
|
||||
"target_type": "direct",
|
||||
"target_id": "alice@imopenfire1-lanzhou",
|
||||
"content_text": "hash mismatch",
|
||||
"content_sha256": sha256HexForSendMessageTest("different body"),
|
||||
"idempotency_key": "idem-hash-mismatch",
|
||||
"execution_mode": "preview",
|
||||
},
|
||||
})
|
||||
if err == nil && !callResult.IsError {
|
||||
t.Fatalf("hash mismatch was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeSendMessageAuditSink struct {
|
||||
events []SendMessageAuditEvent
|
||||
}
|
||||
|
||||
func (f *fakeSendMessageAuditSink) AppendSendMessageAudit(_ context.Context, event SendMessageAuditEvent) error {
|
||||
f.events = append(f.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sha256HexForSendMessageTest(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
Reference in New Issue
Block a user