1package daemon23import (4 "context"5 "encoding/base64"6 "encoding/json"7 "strings"8 "testing"910 "github.com/FelineStateMachine/tinyrelay/internal/event"11 "github.com/FelineStateMachine/tinyrelay/internal/mcp"12)1314func 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().Owner25 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 string72 body []byte73 }{{"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]any94 _ = 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}104105func mcpJSON(value any) []byte { raw, _ := json.Marshal(value); return raw }106107func 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}131132func 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"] = invalid141 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"] = invalid152 if _, err := mcpBuildMessage(call); err == nil {153 t.Errorf("unsafe MIME accepted: %q", invalid)154 }155 }156}