diff --git a/client/client.go b/client/client.go index 904eba12..261924de 100644 --- a/client/client.go +++ b/client/client.go @@ -71,6 +71,14 @@ func (c *CSAPI) CreateMedia(t ct.TestLike) string { return GetJSONFieldStr(t, body, "content_uri") } +// CreateMedia creates an MXC URI for asynchronous media uploads. +func (c *CSAPI) MustCreateMediaRestricted(t ct.TestLike) string { + t.Helper() + res := c.MustDo(t, "POST", []string{"_matrix", "client", "unstable", "org.matrix.msc3911", "media", "create"}) + body := ParseJSON(t, res) + return GetJSONFieldStr(t, body, "content_uri") +} + // UploadMediaAsync uploads the provided content to the given server and media ID. Fails the test on error. func (c *CSAPI) UploadMediaAsync(t ct.TestLike, serverName, mediaID string, fileBody []byte, fileName string, contentType string) { t.Helper() @@ -99,6 +107,23 @@ func (c *CSAPI) UploadContent(t ct.TestLike, fileBody []byte, fileName string, c return GetJSONFieldStr(t, body, "content_uri") } +// MustUploadContentRestricted uploads the provided content with and attachment parameter and an optional file name. Fails the test on error. Returns the MXC URI. +func (c *CSAPI) MustUploadContentRestricted(t ct.TestLike, fileBody []byte, fileName string, contentType string) string { + t.Helper() + query := url.Values{} + if fileName != "" { + query.Set("filename", fileName) + } + res := c.Do( + // /_matrix/client/unstable/org.matrix.msc3911/media/upload + t, "POST", []string{"_matrix", "client", "unstable", "org.matrix.msc3911", "media", "upload"}, + WithRawBody(fileBody), WithContentType(contentType), WithQueries(query), + ) + mustRespond2xx(t, res) + body := ParseJSON(t, res) + return GetJSONFieldStr(t, body, "content_uri") +} + // DownloadContent downloads media from the server, returning the raw bytes and the Content-Type. Fails the test on error. func (c *CSAPI) DownloadContent(t ct.TestLike, mxcUri string) ([]byte, string) { t.Helper() @@ -125,6 +150,16 @@ func (c *CSAPI) DownloadContentAuthenticated(t ct.TestLike, mxcUri string) ([]by return b, contentType } +// UncheckedDownloadContentAuthenticated makes the raw request for a piece of media and returns the http.Response. +// Response is unchecked in any way. The existing DownloadContentAuthenticated() should have been a "Must" variant. Rather +// than refactor that across the code base, this version just uses an explicit name +func (c *CSAPI) UncheckedDownloadContentAuthenticated(t ct.TestLike, mxcUri string) *http.Response { + t.Helper() + origin, mediaId := SplitMxc(mxcUri) + res := c.Do(t, "GET", []string{"_matrix", "client", "v1", "media", "download", origin, mediaId}) + return res +} + // MustCreateRoom creates a room with an optional HTTP request body. Fails the test on error. Returns the room ID. func (c *CSAPI) MustCreateRoom(t ct.TestLike, reqBody map[string]interface{}) string { t.Helper() @@ -337,6 +372,15 @@ func (c *CSAPI) Unsafe_SendEventUnsynced(t ct.TestLike, roomID string, e b.Event return c.Unsafe_SendEventUnsyncedWithTxnID(t, roomID, e, strconv.Itoa(txnID)) } +// Unsafe_SendEventWithAttachedMediaUnsynced sends `e` with a media attachment into the room. This function is UNSAFE as it does not wait +// for the event to be fully processed. This can cause flakey tests. Prefer `SendEventSynced`. +// Returns the event ID of the sent event. +func (c *CSAPI) Unsafe_SendEventWithAttachedMediaUnsynced(t ct.TestLike, roomID string, e b.Event, mxcUri string) string { + t.Helper() + txnID := int(atomic.AddInt64(&c.txnID, 1)) + return c.Unsafe_SendEventWithAttachedMediaUnsyncedWithTxnID(t, roomID, e, mxcUri, strconv.Itoa(txnID)) +} + // SendEventUnsyncedWithTxnID sends `e` into the room with a prescribed transaction ID. // This is useful for writing tests that interrogate transaction semantics. This function is UNSAFE // as it does not wait for the event to be fully processed. This can cause flakey tests. Prefer `SendEventSynced`. @@ -356,6 +400,29 @@ func (c *CSAPI) Unsafe_SendEventUnsyncedWithTxnID(t ct.TestLike, roomID string, return eventID } +// Unsafe_SendEventWithAttachedMediaUnsyncedWithTxnID sends `e` with a media attachment into the room with a prescribed transaction ID. +// This is useful for writing tests that interrogate transaction semantics. This function is UNSAFE +// as it does not wait for the event to be fully processed. This can cause flakey tests. Prefer `SendEventSynced`. +// Returns the event ID of the sent event. +func (c *CSAPI) Unsafe_SendEventWithAttachedMediaUnsyncedWithTxnID(t ct.TestLike, roomID string, e b.Event, mxcUri string, txnID string) string { + t.Helper() + paths := []string{"_matrix", "client", "v3", "rooms", roomID, "send", e.Type, txnID} + if e.StateKey != nil { + paths = []string{"_matrix", "client", "v3", "rooms", roomID, "state", e.Type, *e.StateKey} + } + if e.Sender != "" && e.Sender != c.UserID { + ct.Fatalf(t, "Event.Sender must not be set, as this is set by the client in use (%s)", c.UserID) + } + queries := url.Values{} + if mxcUri != "" { + queries.Add("org.matrix.msc3911.attach_media", mxcUri) + } + res := c.MustDo(t, "PUT", paths, WithJSONBody(t, e.Content), WithQueries(queries)) + body := ParseJSON(t, res) + eventID := GetJSONFieldStr(t, body, "event_id") + return eventID +} + // SendEventSynced sends `e` into the room and waits for its event ID to come down /sync. // Returns the event ID of the sent event. func (c *CSAPI) SendEventSynced(t ct.TestLike, roomID string, e b.Event) string { @@ -368,6 +435,18 @@ func (c *CSAPI) SendEventSynced(t ct.TestLike, roomID string, e b.Event) string return eventID } +// SendEventWithAttachedMediaSynced sends `e` with a media attachment into the room and waits for its event ID to come down /sync. +// Returns the event ID of the sent event. +func (c *CSAPI) SendEventWithAttachedMediaSynced(t ct.TestLike, roomID string, e b.Event, mxcUri string) string { + t.Helper() + eventID := c.Unsafe_SendEventWithAttachedMediaUnsynced(t, roomID, e, mxcUri) + t.Logf("SendEventSynced waiting for event ID %s", eventID) + c.MustSyncUntil(t, SyncReq{}, SyncTimelineHas(roomID, func(r gjson.Result) bool { + return r.Get("event_id").Str == eventID + })) + return eventID +} + // SendRedaction sends a redaction request. Will fail if the returned HTTP request code is not 200. Returns the // event ID of the redaction event. func (c *CSAPI) MustSendRedaction(t ct.TestLike, roomID string, content map[string]interface{}, eventID string) string { @@ -441,6 +520,17 @@ func (c *CSAPI) GetDefaultRoomVersion(t ct.TestLike) gomatrixserverlib.RoomVersi return gomatrixserverlib.RoomVersion(defaultVersion.Str) } +// GetVersions queries the server's client versions +func (c *CSAPI) GetVersions(t ct.TestLike) []byte { + t.Helper() + res := c.MustDo(t, "GET", []string{"_matrix", "client", "versions"}) + body, err := io.ReadAll(res.Body) + if err != nil { + ct.Fatalf(t, "unable to read response body: %v", err) + } + return body +} + // MustUploadKeys uploads device and/or one time keys to the server, returning the current OTK counts. // Both device keys and one time keys are optional. Fails the test if the upload fails. func (c *CSAPI) MustUploadKeys(t ct.TestLike, deviceKeys map[string]interface{}, oneTimeKeys map[string]interface{}) (otkCounts map[string]int) { @@ -545,11 +635,20 @@ func (c *CSAPI) MustGenerateOneTimeKeys(t ct.TestLike, otkCount uint) (deviceKey // MustSetDisplayName sets the global display name for this account or fails the test. func (c *CSAPI) MustSetDisplayName(t ct.TestLike, displayname string) { + t.Helper() c.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "profile", c.UserID, "displayname"}, WithJSONBody(t, map[string]any{ "displayname": displayname, })) } +// MustSetDisplayName sets the global display name for this account or fails the test. +func (c *CSAPI) MustSetProfileAvatar(t ct.TestLike, mxcUri string) { + t.Helper() + c.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "profile", c.UserID, "avatar_url"}, WithJSONBody(t, map[string]any{ + "avatar_url": mxcUri, + })) +} + // MustGetDisplayName returns the global display name for this user or fails the test. func (c *CSAPI) MustGetDisplayName(t ct.TestLike, userID string) string { res := c.MustDo(t, "GET", []string{"_matrix", "client", "v3", "profile", userID, "displayname"}) diff --git a/tests/csapi/room_profile_test.go b/tests/csapi/room_profile_test.go index d98c4379..42e3f815 100644 --- a/tests/csapi/room_profile_test.go +++ b/tests/csapi/room_profile_test.go @@ -11,20 +11,18 @@ import ( ) func TestAvatarUrlUpdate(t *testing.T) { - testProfileFieldUpdate(t, "avatar_url") + testProfileFieldUpdate(t, "avatar_url", "mxc://example.com/LemurLover") } func TestDisplayNameUpdate(t *testing.T) { - testProfileFieldUpdate(t, "displayname") + testProfileFieldUpdate(t, "displayname", "LemurLover") } // sytest: $datum updates affect room member events -func testProfileFieldUpdate(t *testing.T, field string) { +func testProfileFieldUpdate(t *testing.T, field string, bogusData string) { deployment := complement.Deploy(t, 1) defer deployment.Destroy(t) - const bogusData = "LemurLover" - alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{}) roomID := alice.MustCreateRoom(t, map[string]interface{}{ diff --git a/tests/msc3911/cs_api_restricted_media_test.go b/tests/msc3911/cs_api_restricted_media_test.go new file mode 100644 index 00000000..211751f4 --- /dev/null +++ b/tests/msc3911/cs_api_restricted_media_test.go @@ -0,0 +1,269 @@ +package tests + +import ( + "bytes" + "testing" + + "github.com/matrix-org/complement" + "github.com/matrix-org/complement/client" + "github.com/matrix-org/complement/helpers" + "github.com/matrix-org/complement/internal/data" + "github.com/matrix-org/complement/match" + "github.com/matrix-org/complement/must" + "github.com/matrix-org/gomatrixserverlib/spec" +) + +// This test series is for checking behavior of MSC3911 - Linking Media to Events + +func TestRestrictedMediaUnstable(t *testing.T) { + deployment := complement.Deploy(t, 2) + defer deployment.Destroy(t) + + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{ + LocalpartSuffix: "alice", + Password: "password", + }) + bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{ + LocalpartSuffix: "bob", + Password: "password", + }) + charlie := deployment.Register(t, "hs1", helpers.RegistrationOpts{ + LocalpartSuffix: "charlie", + Password: "password", + }) + + alice.LoginUser(t, alice.UserID, "password") + + // gjson look ups that have periods need to be escaped or it thinks it is a path to another object + SkipTestIfNotCapable(t, alice, `org\.matrix\.msc3911`) + + // Let's give alice a profile avatar + aliceGlobalProfileAvatarMxcUri := alice.MustUploadContentRestricted(t, data.MatrixSvg, "alice_avatar.svg", "img/svg") + alice.MustSetProfileAvatar(t, aliceGlobalProfileAvatarMxcUri) + + // Test that the profile that was just set is viewable and not restricted + aliceOriginalProfileBytes, _ := alice.DownloadContentAuthenticated(t, aliceGlobalProfileAvatarMxcUri) + bob.LoginUser(t, bob.UserID, "password") + charlie.LoginUser(t, charlie.UserID, "password") + + // The first four test check that a message event, such as an image, is restricted correctly in different room history + // visibility scenarios + + // Test media uploaded to a "joined" history visibility room is only viewable by the room's joined participants + t.Run("TestJoinedVisibilityMediaMessage", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "joined") + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + mxcUri, message_event_id := MustUploadMediaAttachToMessageEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, message_event_id)) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // charlie is a little trouble maker and found alice's picture mxc uri + // Since charlie can not see into the room, the media is not downloadable + response := charlie.UncheckedDownloadContentAuthenticated(t, mxcUri) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 403}) + }) + + // Test media uploaded to a "invited" history visibility room is only viewable by the room's invited participants + t.Run("TestInvitedVisibilityMediaMessage", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "invited") + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + + mxcUri, _ := MustUploadMediaAttachToMessageEventAndSendIntoRoom(t, alice, roomID) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // charlie is a little trouble maker and found alice's picture mxc uri + // Since charlie can not see into the room, the media is not downloadable + response := charlie.UncheckedDownloadContentAuthenticated(t, mxcUri) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 403}) + + }) + + // Test media uploaded to a "shared" history visibility room is viewable by any one(this is wrong, but how it is with Synapse) + t.Run("TestSharedVisibilityMediaMessage", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "shared") + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + mxcUri, message_event_id := MustUploadMediaAttachToMessageEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, message_event_id)) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // because of the shared visibility being incorrect in synapse, this is viewable + charlie.DownloadContentAuthenticated(t, mxcUri) + }) + + // Test media uploaded to a "world_viewable" history visibility room is viewable by any one + t.Run("TestWorldReadableVisibilityMediaMessage", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "world_readable") + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + mxcUri, message_event_id := MustUploadMediaAttachToMessageEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, message_event_id)) + + // This is a world_readable room, everyone can see this media + bob.DownloadContentAuthenticated(t, mxcUri) + charlie.DownloadContentAuthenticated(t, mxcUri) + }) + + // The MembershipAvatar series tests that media restricted to a membership state event is viewable. + + // Test profile avatar for membership event is only viewable by joined room participants + t.Run("TestJoinedVisibilityMembershipAvatar", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "joined") + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + mxcUri, membership_event_id := MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, membership_event_id)) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // charlie is a little trouble maker and found alice's picture mxc uri + // Since charlie can not see into the room, the media is not downloadable + response := charlie.UncheckedDownloadContentAuthenticated(t, mxcUri) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 403}) + }) + + // Test profile avatar for membership event is only viewable by invited room participants + t.Run("TestInvitedVisibilityMembershipAvatar", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "invited") + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + // Notice that bob is not joined to the room + + mxcUri, _ := MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t, alice, roomID) + + // bob should have the right to see that media. Not sure how he got the mxc without being in the room, but it is allowed. + bob.DownloadContentAuthenticated(t, mxcUri) + + // charlie is a little trouble maker and found alice's picture mxc uri + // Since charlie can not see into the room, the media is not downloadable + response := charlie.UncheckedDownloadContentAuthenticated(t, mxcUri) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 403}) + }) + + // Test profile avatar for membership event is viewable by room participants + t.Run("TestSharedVisibilityMembershipAvatar", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "shared") + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + mxcUri, membership_event_id := MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, membership_event_id)) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // because of the shared visibility being incorrect in synapse, this is viewable + charlie.DownloadContentAuthenticated(t, mxcUri) + }) + + // Test profile avatar for membership event is viewable by room participants + t.Run("TestWorldReadableVisibilityMembershipAvatar", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "world_readable") + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + mxcUri, membership_event_id := MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, membership_event_id)) + + // This is a world_readable room, everyone can see this media + bob.DownloadContentAuthenticated(t, mxcUri) + charlie.DownloadContentAuthenticated(t, mxcUri) + }) + + // Test global profile avatar is copied for initial membership event in a room. This will end up being viewable by anyone(thanks + // to Synapse shared view bug), that is not what is being tested here, so use the most restricive history visibility. + // Note: the global profile that was established on login is the SVG file and our membership state tests above use the PNG file. + t.Run("TestRoomCreationMembershipAvatarIsACopy", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "joined") + // Retrieve alice's membership event so we can pry the avatar_url out of it + aliceMemberEvent := alice.MustGetStateEventContent(t, roomID, "m.room.member", alice.UserID) + + avatarUrl := aliceMemberEvent.Get("avatar_url").Str + + // make sure it is different than the global profile mxc + if avatarUrl == aliceGlobalProfileAvatarMxcUri { + t.Fatalf("mxcUri values should not match for global profile and room membership event") + } + + // download it and check that the bytes match + aliceNewAvatarBytes, _ := alice.DownloadContentAuthenticated(t, avatarUrl) + if !bytes.Equal(aliceOriginalProfileBytes, aliceNewAvatarBytes) { + t.Fatalf("Media is differing and should be identical") + } + + }) + + // Test the media copy endpoint can produce a byte identical copy of a piece of media while also changing it's mxc uri + t.Run("TestMediaCopy", func(t *testing.T) { + // alice has an existing global profile avatar, it's mxc is available at aliceGlobalProfileAvatarMxcUri + // it's []byte is available at aliceOriginalProfileBytes + // We will reuse that and copy it + + originalOrigin, originalMediaID := client.SplitMxc(aliceGlobalProfileAvatarMxcUri) + // Use bob to make the copy. The media should be viewable as it's a global profile + res := bob.MustDo(t, "POST", []string{"_matrix", "client", "unstable", "org.matrix.msc3911", "media", "copy", originalOrigin, originalMediaID}, client.WithJSONBody(t, map[string]any{})) + + body := client.ParseJSON(t, res) + newMxcUri := client.GetJSONFieldStr(t, body, "content_uri") + + aliceNewAvatarBytes, _ := bob.DownloadContentAuthenticated(t, newMxcUri) + if !bytes.Equal(aliceOriginalProfileBytes, aliceNewAvatarBytes) { + t.Fatalf("Media is differing and should be identical") + } + + if newMxcUri == aliceGlobalProfileAvatarMxcUri { + t.Fatalf("MXC match and should be different") + } + }) + + t.Run("TestMediaCopyNonexistingFile", func(t *testing.T) { + // cheat a little on making a media id, it should not exist after all + response := bob.Do(t, "POST", []string{"_matrix", "client", "unstable", "org.matrix.msc3911", "media", "copy", "hs1", "fAkEYfaKeyMeDiAId"}, client.WithJSONBody(t, map[string]any{})) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 404}) + }) +} diff --git a/tests/msc3911/federation_restricted_media_test.go b/tests/msc3911/federation_restricted_media_test.go new file mode 100644 index 00000000..c45fc4e8 --- /dev/null +++ b/tests/msc3911/federation_restricted_media_test.go @@ -0,0 +1,339 @@ +package tests + +import ( + "bytes" + "strings" + "testing" + + "github.com/matrix-org/complement" + "github.com/matrix-org/complement/client" + "github.com/matrix-org/complement/helpers" + "github.com/matrix-org/complement/internal/data" + "github.com/matrix-org/complement/match" + "github.com/matrix-org/complement/must" + "github.com/matrix-org/gomatrixserverlib/spec" +) + +// This test series is for checking behavior of MSC3911 - Linking Media to Events +// Specifically, these are to test the behavior over federated homeservers + +// Note: Most of the functionality of MSC3911 is about being able/unable to retrieve a piece of media referenced +// by a MXC, so a sentinel user will be joined to the room from the remote homeserver. Otherwise, visibility can +// not be properly checked. A piece of media should not be retrievable by a user on a homeserver that did not have +// the event that references it(such as a picture message). I.E. If the picture message with an MXC is not on the +// homeserver to send to it's client, then how would the client ever possibly be expected to be able to view the media? +// The sentinel user guarantees that at the very least, the event involved will be on both servers before the check can +// take place. + +func TestFederationRestrictedMediaUnstable(t *testing.T) { + deployment := complement.Deploy(t, 2) + defer deployment.Destroy(t) + + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{ + LocalpartSuffix: "alice", + Password: "password", + }) + bob := deployment.Register(t, "hs2", helpers.RegistrationOpts{ + LocalpartSuffix: "bob", + Password: "password", + }) + charlie := deployment.Register(t, "hs2", helpers.RegistrationOpts{ + LocalpartSuffix: "charlie", + Password: "password", + }) + + alice.LoginUser(t, alice.UserID, "password") + + // gjson look ups that have periods need to be escaped or it thinks it is a path to another object + SkipTestIfNotCapable(t, alice, `org\.matrix\.msc3911`) + + // Let's give alice a profile avatar + aliceGlobalProfileAvatarMxcUri := alice.MustUploadContentRestricted(t, data.MatrixSvg, "alice_avatar.svg", "img/svg") + alice.MustSetProfileAvatar(t, aliceGlobalProfileAvatarMxcUri) + + // Test that the profile that was just set is viewable and not restricted. Save the bytes payload for later testing + aliceOriginalProfileBytes, _ := alice.DownloadContentAuthenticated(t, aliceGlobalProfileAvatarMxcUri) + bob.LoginUser(t, bob.UserID, "password") + charlie.LoginUser(t, charlie.UserID, "password") + + // The first four test check that a message event, such as an image, is restricted correctly in different room history + // visibility scenarios + + // Test media uploaded to a "joined" history visibility room is only viewable by the room's joined participants + t.Run("TestJoinedVisibilityMediaMessage", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "joined") + MustJoinSentinelToRoom(t, deployment, alice, roomID) + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + // Make sure alice see that bob has joined, otherwise the async nature of federation cause the room join to race with the + // picture message being sent and it will not be viewable properly by bob. + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + mxcUri, message_event_id := MustUploadMediaAttachToMessageEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, message_event_id)) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // charlie is a little trouble maker and found alice's picture mxc uri + // Since charlie can not see into the room, the media is not downloadable + response := charlie.UncheckedDownloadContentAuthenticated(t, mxcUri) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 403}) + + }) + + // Test media uploaded to a "invited" history visibility room is only viewable by the room's invited participants + t.Run("TestInvitedVisibilityMediaMessage", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "invited") + sentinel := MustJoinSentinelToRoom(t, deployment, alice, roomID) + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + // Notice that bob is not joined to this room + + mxcUri, message_event_id := MustUploadMediaAttachToMessageEventAndSendIntoRoom(t, alice, roomID) + + // Use the sentinel user to make sure the message has federated before continuing + sentinel.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, message_event_id)) + + // bob should have the right to see that media. + bob.DownloadContentAuthenticated(t, mxcUri) + + // charlie is a little trouble maker and found alice's picture mxc uri + // Since charlie can not see into the room, the media is not downloadable + response := charlie.UncheckedDownloadContentAuthenticated(t, mxcUri) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 403}) + }) + + // Test media uploaded to a "shared" history visibility room is viewable by any one(this is wrong, but how it is with Synapse) + t.Run("TestSharedVisibilityMediaMessage", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "shared") + MustJoinSentinelToRoom(t, deployment, alice, roomID) + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + mxcUri, message_event_id := MustUploadMediaAttachToMessageEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, message_event_id)) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // because of the shared visibility being incorrect in synapse, this is viewable + charlie.DownloadContentAuthenticated(t, mxcUri) + }) + + // Test media uploaded to a "world_viewable" history visibility room is viewable by any one + t.Run("TestWorldReadableVisibilityMediaMessage", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "world_readable") + MustJoinSentinelToRoom(t, deployment, alice, roomID) + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + mxcUri, message_event_id := MustUploadMediaAttachToMessageEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, message_event_id)) + + // This is a world_readable room, everyone can see this media + bob.DownloadContentAuthenticated(t, mxcUri) + charlie.DownloadContentAuthenticated(t, mxcUri) + }) + + // The MembershipAvatar series tests that media restricted to a membership state event is viewable. + + // Test profile avatar for membership event is only viewable by "joined" room participants + t.Run("TestJoinedVisibilityMembershipAvatar", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "joined") + MustJoinSentinelToRoom(t, deployment, alice, roomID) + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + // Make sure alice see that bob has joined, otherwise the async nature of federation cause the room join to race with the + // picture message being sent and it will not be viewable properly by bob. + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + mxcUri, membership_event_id := MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, membership_event_id)) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // charlie is a little trouble maker and found alice's picture mxc uri + // Since charlie can not see into the room, the media is not downloadable + response := charlie.UncheckedDownloadContentAuthenticated(t, mxcUri) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 403}) + }) + + // Test profile avatar for membership event is only viewable by invited room participants + t.Run("TestInvitedVisibilityMembershipAvatar", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "invited") + sentinel := MustJoinSentinelToRoom(t, deployment, alice, roomID) + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + // Notice that bob is not joined to the room + + mxcUri, message_event_id := MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t, alice, roomID) + + // Use the sentinel user to make sure the event has federated before continuing + sentinel.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, message_event_id)) + + // bob should have the right to see that media. + bob.DownloadContentAuthenticated(t, mxcUri) + + // charlie is a little trouble maker and found alice's picture mxc uri + // Since charlie can not see into the room, the media is not downloadable + response := charlie.UncheckedDownloadContentAuthenticated(t, mxcUri) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 403}) + }) + + // Test profile avatar for membership event is viewable by room participants + t.Run("TestSharedVisibilityMembershipAvatar", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "shared") + MustJoinSentinelToRoom(t, deployment, alice, roomID) + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + mxcUri, membership_event_id := MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, membership_event_id)) + + // bob should have the right to see that media + bob.DownloadContentAuthenticated(t, mxcUri) + + // because of the shared visibility being incorrect in synapse, this is viewable + charlie.DownloadContentAuthenticated(t, mxcUri) + }) + + // Test profile avatar for membership event is viewable by room participants + t.Run("TestWorldReadableVisibilityMembershipAvatar", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "world_readable") + MustJoinSentinelToRoom(t, deployment, alice, roomID) + + alice.MustInviteRoom(t, roomID, bob.UserID) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(bob.UserID, roomID)) + bob.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + mxcUri, membership_event_id := MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t, alice, roomID) + + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHasEventID(roomID, membership_event_id)) + + // This is a world_readable room, everyone can see this media + bob.DownloadContentAuthenticated(t, mxcUri) + charlie.DownloadContentAuthenticated(t, mxcUri) + }) + + // Test global profile avatar is copied for initial membership event in a room. This will end up being viewable by anyone(thanks + // to Synapse shared view bug), that is not what is being tested here, so use the most restricive history visibility. + // Note: the global profile that was established on login is the SVG file and our membership state tests above use the PNG file. + t.Run("TestRoomCreationMembershipAvatarIsACopy", func(t *testing.T) { + roomID := MustCreateRoomWithHistoryVisibility(t, alice, "joined") + // Retrieve alice's membership event so we can pry the avatar_url out of it + aliceMemberEvent := alice.MustGetStateEventContent(t, roomID, "m.room.member", alice.UserID) + + avatarUrl := aliceMemberEvent.Get("avatar_url").Str + + // make sure it is different than the global profile mxc + if avatarUrl == aliceGlobalProfileAvatarMxcUri { + t.Fatalf("mxcUri values should not match for global profile and room membership event") + } + + // download it and check that the bytes match + aliceNewAvatarBytes, _ := alice.DownloadContentAuthenticated(t, avatarUrl) + if !bytes.Equal(aliceOriginalProfileBytes, aliceNewAvatarBytes) { + t.Fatalf("Media is differing and should be identical") + } + + }) + + // Test the media copy endpoint can produce a byte identical copy of a piece of media while also changing it's mxc uri + t.Run("TestMediaCopy", func(t *testing.T) { + // alice has an existing global profile avatar, it's mxc is available at aliceGlobalProfileAvatarMxcUri + // it's []byte is available at aliceOriginalProfileBytes + // We will reuse that and copy it + + // There seem to be no existing utilities to split an mxc uri into it's components, which is needed for the copy endpoint. + // Mxc uri's are formatted as "mxc://server_name/media_id" + + // Cut the "mxc://" off the front + existSplitMxc, found := strings.CutPrefix(aliceGlobalProfileAvatarMxcUri, "mxc://") + if !found { + t.Fatalf("mxc was malformed %s", aliceGlobalProfileAvatarMxcUri) + } + // Split the remaining into the server name and the media id + mxcComponents := strings.Split(existSplitMxc, "/") + + // Use bob to make the copy. The media should be viewable as it's a global profile + res := bob.MustDo(t, "POST", []string{"_matrix", "client", "unstable", "org.matrix.msc3911", "media", "copy", mxcComponents[0], mxcComponents[1]}, client.WithJSONBody(t, map[string]any{})) + + body := client.ParseJSON(t, res) + newMxcUri := client.GetJSONFieldStr(t, body, "content_uri") + + aliceNewAvatarBytes, _ := bob.DownloadContentAuthenticated(t, newMxcUri) + if !bytes.Equal(aliceOriginalProfileBytes, aliceNewAvatarBytes) { + t.Fatalf("Media is differing and should be identical") + } + + }) + + t.Run("TestMediaCopyNonexistingFile", func(t *testing.T) { + // cheat a little on making a media id, it should not exist after all + response := bob.Do(t, "POST", []string{"_matrix", "client", "unstable", "org.matrix.msc3911", "media", "copy", "hs1", "fAkEYfaKeyMeDiAId"}, client.WithJSONBody(t, map[string]any{})) + must.MatchResponse(t, response, match.HTTPResponse{StatusCode: 404}) + }) + +} + +// Tank has one job, be part of the room so that federation works. No other interaction except running sync to make +// sure an event has arrived. +func MustJoinSentinelToRoom(t *testing.T, deployment complement.Deployment, activeUser *client.CSAPI, roomID string) *client.CSAPI { + sentinelUser := deployment.Register(t, "hs2", helpers.RegistrationOpts{ + LocalpartSuffix: "Tank", + Password: "password", + }) + sentinelUser.LoginUser(t, sentinelUser.UserID, "password") + + activeUser.MustInviteRoom(t, roomID, sentinelUser.UserID) + sentinelUser.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(sentinelUser.UserID, roomID)) + + sentinelUser.MustJoinRoom(t, roomID, []spec.ServerName{ + deployment.GetFullyQualifiedHomeserverName(t, "hs1"), + }) + + return sentinelUser + +} diff --git a/tests/msc3911/main_test.go b/tests/msc3911/main_test.go new file mode 100644 index 00000000..7377d1bf --- /dev/null +++ b/tests/msc3911/main_test.go @@ -0,0 +1,83 @@ +package tests + +import ( + "testing" + + "github.com/matrix-org/complement" + "github.com/matrix-org/complement/b" + "github.com/matrix-org/complement/client" + "github.com/matrix-org/complement/internal/data" + "github.com/tidwall/gjson" +) + +func TestMain(m *testing.M) { + complement.TestMain(m, "msc3911") +} + +// Create a room with a history visibility as specificed +func MustCreateRoomWithHistoryVisibility(t *testing.T, creatingUserClient *client.CSAPI, historyVisiblity string) string { + roomID := creatingUserClient.MustCreateRoom(t, map[string]interface{}{ + "preset": "public_chat", + "name": "Room", + "room_version": "11", + "initial_state": []map[string]interface{}{ + { + "type": "m.room.history_visibility", + "state_key": "", + "content": map[string]interface{}{ + "history_visibility": historyVisiblity, + }, + }, + }, + }) + + return roomID +} + +// testing helper to send a message event of type m.image into a room with attached media. Returns the mxcUri and the +// event ID of the message event +func MustUploadMediaAttachToMessageEventAndSendIntoRoom(t *testing.T, sendingUser *client.CSAPI, roomID string) (string, string) { + mxcUri := sendingUser.MustUploadContentRestricted(t, data.MatrixPng, "test.png", "img/png") + picture_message := b.Event{ + Type: "m.room.message", + Content: map[string]interface{}{ + "msgtype": "m.image", + "body": "test.png", + "url": mxcUri, + }, + Sender: sendingUser.UserID, + } + + event_id := sendingUser.SendEventWithAttachedMediaSynced(t, roomID, picture_message, mxcUri) + return mxcUri, event_id +} + +// testing helper to send a state membership event with attached media into a room. Returns the mxcUri and the event ID +// of the membership event +func MustUploadMediaAttachToMembershipEventAndSendIntoRoom(t *testing.T, sendingUser *client.CSAPI, roomID string) (string, string) { + mxcUri := sendingUser.MustUploadContentRestricted(t, data.MatrixPng, "test.png", "img/png") + picture_message := b.Event{ + Type: "m.room.member", + Content: map[string]interface{}{ + "membership": "join", + "avatar_url": mxcUri, + }, + Sender: sendingUser.UserID, + StateKey: &sendingUser.UserID, + } + + event_id := sendingUser.SendEventWithAttachedMediaSynced(t, roomID, picture_message, mxcUri) + return mxcUri, event_id +} + +// Check the server's client version endpoint for an unstable feature to be enabled. If it is not found, skip the test +func SkipTestIfNotCapable(t *testing.T, client *client.CSAPI, feature string) { + versionsObject := client.GetVersions(t) + unstableFeatures := gjson.GetBytes(versionsObject, "unstable_features") + + thingEnabled := unstableFeatures.Get(feature) + if thingEnabled.Type != gjson.True { + t.Skipf("%s not supported on this homeserver", feature) + } + +}