tinyrelayfile
tinyrelay/internal/daemon/mcp_attachments_test.go/

internal/daemon/mcp_attachments_test.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/base64"
6 "encoding/json"
7 "strings"
8 "testing"
9
10 "github.com/FelineStateMachine/tinyrelay/internal/event"
11 "github.com/FelineStateMachine/tinyrelay/internal/mcp"
12)
13
14func TestMCPAttachmentRegistryRoundTrip(t *testing.T) {
15 _, tenant := testTenant(t)
16 registry, err := tenant.mcpTools()
17 if err != nil {
18 t.Fatal(err)
19 }
20 upload, ok := registry.Lookup("upload_attachment")
21 if !ok {
22 t.Fatal("upload_attachment is not registered")
23 }
24 owner := tenant.Policy().Owner
25 raw := []byte("agent document")
26 result, err := upload.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "data": base64.StdEncoding.EncodeToString(raw), "type": "text/plain", "filename": "note.txt"}})
27 if err != nil || result.IsError {
28 t.Fatalf("upload: %v %+v", err, result)
29 }
30 descriptor := result.StructuredContent.(map[string]any)
31 encodedDescriptor, err := json.Marshal(descriptor)
32 if err != nil {
33 t.Fatal(err)
34 }
35 if err := json.Unmarshal(encodedDescriptor, &descriptor); err != nil {
36 t.Fatal(err)
37 }
38 if descriptor["sha256"] == "" || descriptor["url"] == "" || descriptor["filename"] != "note.txt" {
39 t.Fatalf("descriptor: %#v", descriptor)
40 }
41 post, ok := registry.Lookup("post_message")
42 if !ok {
43 t.Fatal("post_message is not registered")
44 }
45 if err := mcp.Validate(post.InputSchema, map[string]any{"room": "main", "attachments": []any{descriptor}}); err != nil {
46 t.Fatalf("descriptor schema: %v", err)
47 }
48 unsigned, err := mcpBuildMessage(mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}})
49 if err != nil || !strings.Contains(unsigned.Content, descriptor["url"].(string)) || len(unsigned.Tags) != 2 {
50 t.Fatalf("attachment-only message: %+v %v", unsigned, err)
51 }
52 if thread, err := mcpBuildThread(mcp.Call{Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}}); err != nil || thread.Content == "" {
53 t.Fatalf("attachment-only thread: %+v %v", thread, err)
54 }
55 if reply, err := mcpBuildReply(mcp.Call{Arguments: map[string]any{"room": "main", "root": strings.Repeat("a", 64), "attachments": []any{descriptor}}}); err != nil || reply.Content == "" {
56 t.Fatalf("attachment-only reply: %+v %v", reply, err)
57 }
58 read, ok := registry.Lookup("read_attachment")
59 if !ok {
60 t.Fatal("read_attachment is not registered")
61 }
62 readResult, err := read.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"sha256": descriptor["sha256"], "max_bytes": float64(1024)}})
63 if err != nil || readResult.IsError {
64 t.Fatalf("read: %v %+v", err, readResult)
65 }
66 readData := readResult.StructuredContent.(map[string]any)["data"].(string)
67 if got, _ := base64.StdEncoding.DecodeString(readData); string(got) != string(raw) {
68 t.Fatalf("read data %q", got)
69 }
70 for _, media := range []struct {
71 typ, want string
72 body []byte
73 }{{"image/png", "image", []byte("\x89PNG\r\n\x1a\n")}, {"audio/ogg", "audio", []byte("OggS test")}} {
74 mediaResult, err := upload.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "data": base64.StdEncoding.EncodeToString(media.body), "type": media.typ}})
75 if err != nil || mediaResult.IsError {
76 t.Fatalf("media upload %s: %v %+v", media.typ, err, mediaResult)
77 }
78 mediaDescriptor := mediaResult.StructuredContent.(map[string]any)
79 got, err := read.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"sha256": mediaDescriptor["sha256"], "max_bytes": float64(1024)}})
80 if err != nil || got.IsError || len(got.Content) != 1 || got.Content[0].Type != media.want {
81 t.Fatalf("media read %s: %v %+v", media.typ, err, got)
82 }
83 }
84 unsignedEvent, err := mcpBuildMessage(mcp.Call{Actor: owner, Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}})
85 if err != nil {
86 t.Fatal(err)
87 }
88 published := event.Event{Kind: unsignedEvent.Kind, CreatedAt: unsignedEvent.CreatedAt, Tags: unsignedEvent.Tags, Content: unsignedEvent.Content}
89 if err := event.Sign(&published, strings.Repeat("0", 63)+"1"); err != nil {
90 t.Fatal(err)
91 }
92 encoded, _ := json.Marshal(published)
93 var signed map[string]any
94 _ = json.Unmarshal(encoded, &signed)
95 posted, err := post.Handler(context.Background(), mcp.Call{Actor: owner, Arguments: map[string]any{"event": signed}})
96 if err != nil || posted.IsError {
97 t.Fatalf("signed attachment post: %v %+v", err, posted)
98 }
99 roomResult, err := tenant.Execute(context.Background(), owner, "browseroom", []json.RawMessage{json.RawMessage(`{"id":"main"}`)})
100 if err != nil || !strings.Contains(string(mcpJSON(roomResult)), "imeta") {
101 t.Fatalf("room lost attachment metadata: %v %#v", err, roomResult)
102 }
103}
104
105func mcpJSON(value any) []byte { raw, _ := json.Marshal(value); return raw }
106
107func TestMCPAttachmentRejectsUnauthorizedAndMalformed(t *testing.T) {
108 _, tenant := testTenant(t)
109 registry, err := tenant.mcpTools()
110 if err != nil {
111 t.Fatal(err)
112 }
113 upload, _ := registry.Lookup("upload_attachment")
114 if result, err := upload.Handler(context.Background(), mcp.Call{Actor: strings.Repeat("f", 64), Arguments: map[string]any{"room": "main", "data": "YQ==", "type": "text/plain"}}); err != nil || !result.IsError {
115 t.Fatalf("unjoined upload accepted: %v %+v", err, result)
116 }
117 if err := mcpCheckAttachments(event.Event{Tags: [][]string{{"imeta", "url https://relay.test/media/not-a-hash", "m text/plain", "x bad"}}}); err == nil {
118 t.Fatal("malformed imeta accepted")
119 }
120 h := newRoomHarness(t)
121 h.must("alice", event.KIND_CREATE_GROUP, [][]string{{"h", "private"}, {"closed"}}, "")
122 stored, err := h.tenant.storeRoomAttachment(h.ctx, h.keys["alice"], "private", roomAttachmentInput{Body: []byte("secret"), Type: "text/plain"})
123 if err != nil {
124 t.Fatal(err)
125 }
126 result, err := h.tenant.mcpReadAttachment(h.ctx, mcp.Call{Actor: h.keys["bob"], Arguments: map[string]any{"sha256": stored["sha256"]}})
127 if err != nil || !result.IsError {
128 t.Fatalf("outsider read accepted: %v %+v", err, result)
129 }
130}
131
132func TestMCPAttachmentEscapingAndValidation(t *testing.T) {
133 descriptor := map[string]any{"url": "https://relay.test/media/" + strings.Repeat("a", 64), "sha256": strings.Repeat("a", 64), "size": float64(8), "type": "text/plain", "filename": "report] [x].txt"}
134 call := mcp.Call{Arguments: map[string]any{"room": "main", "attachments": []any{descriptor}}}
135 unsigned, err := mcpBuildMessage(call)
136 if err != nil || !strings.HasPrefix(unsigned.Content, `[report\] \[x\].txt](`) {
137 t.Fatalf("filename escaping: %q %v", unsigned.Content, err)
138 }
139 for _, invalid := range []string{"javascript:alert(1)", "https://user:password@relay.test/a", "https://relay.test/a)\n![bad](https://tracker.test)", "https://relay.test/a b"} {
140 descriptor["url"] = invalid
141 if _, err := mcpBuildMessage(call); err == nil {
142 t.Errorf("unsafe URL accepted: %q", invalid)
143 }
144 }
145 descriptor["url"] = "https://relay.test/video.mp4"
146 descriptor["type"] = "video/mp4"
147 if unsigned, err := mcpBuildMessage(call); err != nil || !strings.HasPrefix(unsigned.Content, "![video](") {
148 t.Fatalf("Buzz video marker: %+v %v", unsigned, err)
149 }
150 for _, invalid := range []string{"invalid", "text/plain\r\nx bad", ""} {
151 descriptor["type"] = invalid
152 if _, err := mcpBuildMessage(call); err == nil {
153 t.Errorf("unsafe MIME accepted: %q", invalid)
154 }
155 }
156}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs