| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 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 | |
| 14 | func 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 | |
| 105 | func mcpJSON(value any) []byte { raw, _ := json.Marshal(value); return raw } |
| 106 | |
| 107 | func 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 | |
| 132 | func 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" , "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, " { |
| 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 | } |