-
Notifications
You must be signed in to change notification settings - Fork 69
Fix flawed membership checks and align membership checking patterns #813
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
558850d
8656464
8788c3c
a820988
cca4368
2ec2fed
3c93edb
ab61fd3
9547994
9cc6af9
680e22a
e1ac188
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import ( | |
| "net/http" | ||
| "net/url" | ||
| "reflect" | ||
| "slices" | ||
| "sort" | ||
| "strings" | ||
| "time" | ||
|
|
@@ -269,95 +270,138 @@ func SyncPresenceHas(fromUser string, expectedPresence *string, checks ...func(g | |
| } | ||
| } | ||
|
|
||
| // Checks that `userID` gets invited to `roomID`. | ||
| // syncMembershipIn checks that `userID` has `membership` in `roomID`, with optional | ||
| // extra checks on the found membership event. | ||
| // | ||
| // This checks different parts of the /sync response depending on the client making the request. | ||
| // If the client is also the person being invited to the room then the 'invite' block will be inspected. | ||
| // If the client is different to the person being invited then the 'join' block will be inspected. | ||
| func SyncInvitedTo(userID, roomID string) SyncCheckOpt { | ||
| return func(clientUserID string, topLevelSyncJSON gjson.Result) error { | ||
| // two forms which depend on what the client user is: | ||
| // - passively viewing an invite for a room you're joined to (timeline events) | ||
| // - actively being invited to a room. | ||
| if clientUserID == userID { | ||
| // active | ||
| err := checkArrayElements( | ||
| topLevelSyncJSON, "rooms.invite."+GjsonEscape(roomID)+".invite_state.events", | ||
| func(ev gjson.Result) bool { | ||
| return ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "invite" | ||
| }, | ||
| ) | ||
| if err != nil { | ||
| return fmt.Errorf("SyncInvitedTo(%s): %s", roomID, err) | ||
| } | ||
| return nil | ||
| } | ||
| // passive | ||
| return SyncTimelineHas(roomID, func(ev gjson.Result) bool { | ||
| return ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "invite" | ||
| })(clientUserID, topLevelSyncJSON) | ||
| } | ||
| } | ||
|
|
||
| // Check that `userID` gets joined to `roomID` by inspecting the join timeline for a membership event. | ||
| // This can be also used to passively observe another user's membership changes in a | ||
| // room although we assume that the observing client is joined to the room. | ||
| // | ||
| // Additional checks can be passed to narrow down the check, all must pass. | ||
| func SyncJoinedTo(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt { | ||
| checkJoined := func(ev gjson.Result) bool { | ||
| if ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "join" { | ||
| // Note: This will not work properly with leave/ban membership for initial syncs, see | ||
| // https://github.com/matrix-org/matrix-doc/issues/3537 | ||
| func syncMembershipIn(userID, roomID, membership string, checks ...func(gjson.Result) bool) SyncCheckOpt { | ||
| checkMembership := func(ev gjson.Result) bool { | ||
| if ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == membership { | ||
| for _, check := range checks { | ||
| if !check(ev) { | ||
| // short-circuit, bail early | ||
| return false | ||
| } | ||
| } | ||
| // passed both basic join check and all other checks | ||
| // passed both basic membership check and all other checks | ||
| return true | ||
| } | ||
| return false | ||
| } | ||
| return func(clientUserID string, topLevelSyncJSON gjson.Result) error { | ||
| // Check both the timeline and the state events for the join event | ||
| // since on initial sync, the state events may only be in | ||
| // <room>.state.events. | ||
| // Check both the timeline and the state events for the membership event since on | ||
| // initial sync, the state events may only be in state. Additionally, state only | ||
| // covers the "updates for the room up to the start of the timeline." | ||
|
|
||
| // We assume the passively observing client user is joined to the room | ||
| roomTypeKey := "join" | ||
| // Otherwise, if the client is the user whose membership we are checking, we need to | ||
| // pick the correct room type JSON key based on the membership being checked. | ||
| if clientUserID == userID { | ||
| if membership == "join" { | ||
| roomTypeKey = "join" | ||
| } else if membership == "leave" || membership == "ban" { | ||
| roomTypeKey = "leave" | ||
| } else if membership == "invite" { | ||
| roomTypeKey = "invite" | ||
| } else if membership == "knock" { | ||
| roomTypeKey = "knock" | ||
| } else { | ||
| return fmt.Errorf("syncMembershipIn(%s, %s): unknown membership: %s", roomID, membership, membership) | ||
| } | ||
| } | ||
|
|
||
| // We assume the passively observing client user is joined to the room (`rooms.join.<roomID>.state`) | ||
| stateKey := "state" | ||
| // Otherwise, if the client is the user whose membership we are checking, | ||
| // we need to pick the correct JSON key based on the membership being checked. | ||
| if clientUserID == userID { | ||
| if membership == "join" || membership == "leave" || membership == "ban" { | ||
| stateKey = "state" | ||
| } else if membership == "invite" { | ||
| stateKey = "invite_state" | ||
| } else if membership == "knock" { | ||
| stateKey = "knock_state" | ||
| } else { | ||
| return fmt.Errorf("syncMembershipIn(%s, %s): unknown membership: %s", roomID, membership, membership) | ||
| } | ||
| } | ||
|
|
||
| // Check the state first as it's a better source of truth than the `timeline`. | ||
| // | ||
| // FIXME: Ideally, we'd use something like `state_after` to get the actual current | ||
| // state in the room instead of us assuming that no state resets/conflicts happen | ||
| // when we apply state from the `timeline` on top of the `state`. But `state_after` | ||
| // is gated behind a sync request parameter which we can't control here. | ||
|
Comment on lines
+336
to
+339
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any reason not to enable
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could work although it's only available in new Matrix versions (added in Something to revisit in the future ⏩ |
||
| firstErr := checkArrayElements( | ||
| topLevelSyncJSON, "rooms.join."+GjsonEscape(roomID)+".timeline.events", checkJoined, | ||
| topLevelSyncJSON, "rooms."+roomTypeKey+"."+GjsonEscape(roomID)+"."+stateKey+".events", checkMembership, | ||
| ) | ||
| if firstErr == nil { | ||
| return nil | ||
| } | ||
|
|
||
| secondErr := checkArrayElements( | ||
| topLevelSyncJSON, "rooms.join."+GjsonEscape(roomID)+".state.events", checkJoined, | ||
| ) | ||
| if secondErr == nil { | ||
| return nil | ||
| // Check the timeline | ||
| // | ||
| // This is also important to differentiate between leave/ban because those both | ||
| // appear in the `leave` `roomTypeKey` and we need to specifically check the | ||
| // timeline for the membership event to differentiate them. | ||
| var secondErr error | ||
| // The `timeline` is only available for join/leave/ban memberships. | ||
| if slices.Contains([]string{"join", "leave", "ban"}, membership) || | ||
| // We assume the passively observing client user is joined to the room (therefore | ||
| // has `timeline`). | ||
| clientUserID != userID { | ||
| secondErr = checkArrayElements( | ||
| topLevelSyncJSON, "rooms."+roomTypeKey+"."+GjsonEscape(roomID)+".timeline.events", checkMembership, | ||
| ) | ||
| if secondErr == nil { | ||
| return nil | ||
| } | ||
| } | ||
| return fmt.Errorf("SyncJoinedTo(%s): %s & %s", roomID, firstErr, secondErr) | ||
|
|
||
| return fmt.Errorf("syncMembershipIn(%s, %s): %s & %s - %s", roomID, membership, firstErr, secondErr, topLevelSyncJSON) | ||
| } | ||
| } | ||
|
|
||
| // Check that `userID` is leaving `roomID` by inspecting the timeline for a membership event, or witnessing `roomID` in `rooms.leave` | ||
| // Checks that `userID` gets invited to `roomID` | ||
| // | ||
| // Additional checks can be passed to narrow down the check, all must pass. | ||
| func SyncInvitedTo(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt { | ||
| return syncMembershipIn(userID, roomID, "invite", checks...) | ||
| } | ||
|
|
||
| // Checks that `userID` has knocked on `roomID` | ||
| // | ||
| // Additional checks can be passed to narrow down the check, all must pass. | ||
| func SyncKnockedOn(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt { | ||
| return syncMembershipIn(userID, roomID, "knock", checks...) | ||
| } | ||
|
|
||
| // Check that `userID` gets joined to `roomID` | ||
| // | ||
| // Additional checks can be passed to narrow down the check, all must pass. | ||
| func SyncJoinedTo(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt { | ||
| return syncMembershipIn(userID, roomID, "join", checks...) | ||
| } | ||
|
|
||
| // Check that `userID` has left the `roomID` | ||
| // Note: This will not work properly with initial syncs, see https://github.com/matrix-org/matrix-doc/issues/3537 | ||
| func SyncLeftFrom(userID, roomID string) SyncCheckOpt { | ||
| return func(clientUserID string, topLevelSyncJSON gjson.Result) error { | ||
| // two forms which depend on what the client user is: | ||
| // - passively viewing a membership for a room you're joined in | ||
| // - actively leaving the room | ||
| if clientUserID == userID { | ||
| // active | ||
| events := topLevelSyncJSON.Get("rooms.leave." + GjsonEscape(roomID)) | ||
| if !events.Exists() { | ||
| return fmt.Errorf("no leave section for room %s", roomID) | ||
| } else { | ||
| return nil | ||
| } | ||
| } | ||
| // passive | ||
| return SyncTimelineHas(roomID, func(ev gjson.Result) bool { | ||
| return ev.Get("type").Str == "m.room.member" && ev.Get("state_key").Str == userID && ev.Get("content.membership").Str == "leave" | ||
| })(clientUserID, topLevelSyncJSON) | ||
| } | ||
| // | ||
| // Additional checks can be passed to narrow down the check, all must pass. | ||
| func SyncLeftFrom(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt { | ||
| return syncMembershipIn(userID, roomID, "leave", checks...) | ||
| } | ||
|
|
||
| // Check that `userID` is banned from the `roomID` | ||
| // Note: This will not work properly with initial syncs, see https://github.com/matrix-org/matrix-doc/issues/3537 | ||
| // | ||
| // Additional checks can be passed to narrow down the check, all must pass. | ||
| func SyncBannedFrom(userID, roomID string, checks ...func(gjson.Result) bool) SyncCheckOpt { | ||
| return syncMembershipIn(userID, roomID, "ban", checks...) | ||
| } | ||
|
|
||
| // Calls the `check` function for each global account data event, and returns with success if the | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,16 +79,7 @@ func TestRoomMembers(t *testing.T) { | |
| }, | ||
| }) | ||
|
|
||
| bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas( | ||
| roomID, | ||
| func(ev gjson.Result) bool { | ||
| if ev.Get("type").Str != "m.room.member" || ev.Get("state_key").Str != bob.UserID { | ||
| return false | ||
| } | ||
| must.Equal(t, ev.Get("content").Get("membership").Str, "join", "Bob failed to join the room") | ||
| return true | ||
| }, | ||
| )) | ||
|
Comment on lines
-82
to
-91
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any of these While, it works well in some tests like this specific one, that's only because the user doesn't have any other previous membership in the room to be confused with. If there was any other previous membership for this user, this is flawed (see #813 (comment) as an example). Instead of leaving these around to be copy-pasted and cargo-culted around, I've updated all of them to use the more proper assertion check. |
||
| bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've double-checked the translation is correct for all of these updates:
|
||
| }) | ||
| // sytest: Test that we can be reinvited to a room we created | ||
| t.Run("Test that we can be reinvited to a room we created", func(t *testing.T) { | ||
|
|
@@ -122,14 +113,7 @@ func TestRoomMembers(t *testing.T) { | |
| alice.MustLeaveRoom(t, roomID) | ||
|
|
||
| // Wait until alice has left the room | ||
| bob.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas( | ||
| roomID, | ||
| func(ev gjson.Result) bool { | ||
| return ev.Get("type").Str == "m.room.member" && | ||
| ev.Get("content.membership").Str == "leave" && | ||
| ev.Get("state_key").Str == alice.UserID | ||
| }, | ||
| )) | ||
|
Comment on lines
-125
to
-132
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For reference, this kind of check was just fine. We return (notice the difference to the above bad form assertion) |
||
| bob.MustSyncUntil(t, client.SyncReq{}, client.SyncLeftFrom(alice.UserID, roomID)) | ||
|
|
||
| bob.MustInviteRoom(t, roomID, alice.UserID) | ||
| since := alice.MustSyncUntil(t, client.SyncReq{}, client.SyncInvitedTo(alice.UserID, roomID)) | ||
|
|
@@ -203,12 +187,7 @@ func TestRoomMembers(t *testing.T) { | |
| }) | ||
| res := alice.Do(t, "POST", []string{"_matrix", "client", "v3", "rooms", roomID, "ban"}, banBody) | ||
| must.MatchResponse(t, res, match.HTTPResponse{StatusCode: 200}) | ||
| alice.MustSyncUntil(t, client.SyncReq{}, client.SyncTimelineHas(roomID, func(ev gjson.Result) bool { | ||
| if ev.Get("type").Str != "m.room.member" || ev.Get("state_key").Str != bob.UserID { | ||
| return false | ||
| } | ||
| return ev.Get("content.membership").Str == "ban" | ||
| })) | ||
| alice.MustSyncUntil(t, client.SyncReq{}, client.SyncBannedFrom(bob.UserID, roomID)) | ||
| // verify bob is banned | ||
| content := alice.MustGetStateEventContent(t, roomID, "m.room.member", bob.UserID) | ||
| must.MatchGJSON(t, content, match.JSONKeyEqual("membership", "ban")) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Spec: https://spec.matrix.org/v1.16/client-server-api/#get_matrixclientv3sync_response-200_rooms