diff --git a/internal/client/client.go b/internal/client/client.go index 04556088..3e96e7a9 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -133,22 +133,41 @@ func (c *CSAPI) SendEventSynced(t *testing.T, roomID string, e b.Event) string { // - we see an event in the room for which the `check` function returns True // If the `check` function fails the test, the failing event will be automatically logged. // Will time out after CSAPI.SyncUntilTimeout. -func (c *CSAPI) SyncUntilTimelineHas(t *testing.T, roomID string, check func(gjson.Result) bool) { +// +// Returns the `next_batch` token from the last /sync response. This can be passed as +// `since` to sync from this point forward only. +func (c *CSAPI) SyncUntilTimelineHas(t *testing.T, roomID string, check func(gjson.Result) bool) string { + t.Helper() + return c.SyncUntil(t, "", "", "rooms.join."+GjsonEscape(roomID)+".timeline.events", check) +} + +// SyncUntilGlobalAccountDataHas is a wrapper around `SyncUntil`. +// It blocks and continually calls `/sync` until +// - we an event in the global account data for which the `check` function returns True +// If the `check` function fails the test, the failing event will be automatically logged. +// Will time out after CSAPI.SyncUntilTimeout. +// +// Returns the `next_batch` token from the last /sync response. This can be passed as +// `since` to sync from this point forward only. +func (c *CSAPI) SyncUntilGlobalAccountDataHas(t *testing.T, check func(gjson.Result) bool) string { t.Helper() - c.SyncUntil(t, "", "", "rooms.join."+GjsonEscape(roomID)+".timeline.events", check) + return c.SyncUntil(t, "", "", "account_data.events", check) } // SyncUntilInvitedTo is a wrapper around SyncUntil. // It blocks and continually calls `/sync` until we've been invited to the given room. // Will time out after CSAPI.SyncUntilTimeout. -func (c *CSAPI) SyncUntilInvitedTo(t *testing.T, roomID string) { +// +// Returns the `next_batch` token from the last /sync response. This can be passed as +// `since` to sync from this point forward only. +func (c *CSAPI) SyncUntilInvitedTo(t *testing.T, roomID string) string { t.Helper() check := func(event gjson.Result) bool { return event.Get("type").Str == "m.room.member" && event.Get("content.membership").Str == "invite" && event.Get("state_key").Str == c.UserID } - c.SyncUntil(t, "", "", "rooms.invite."+GjsonEscape(roomID)+".invite_state.events", check) + return c.SyncUntil(t, "", "", "rooms.invite."+GjsonEscape(roomID)+".invite_state.events", check) } // SyncUntil blocks and continually calls /sync until @@ -157,7 +176,10 @@ func (c *CSAPI) SyncUntilInvitedTo(t *testing.T, roomID string) { // - some element in that array makes the `check` function return true. // If the `check` function fails the test, the failing event will be automatically logged. // Will time out after CSAPI.SyncUntilTimeout. -func (c *CSAPI) SyncUntil(t *testing.T, since, filter, key string, check func(gjson.Result) bool) { +// +// Returns the `next_batch` token from the last /sync response. This can be passed as +// `since` to sync from this point forward only. +func (c *CSAPI) SyncUntil(t *testing.T, since, filter, key string, check func(gjson.Result) bool) string { t.Helper() start := time.Now() checkCounter := 0 @@ -199,7 +221,7 @@ func (c *CSAPI) SyncUntil(t *testing.T, since, filter, key string, check func(gj for i, ev := range events { lastEvent = &events[i] if check(ev) { - return + return since } wasFailed = t.Failed() checkCounter++ diff --git a/internal/match/json.go b/internal/match/json.go index b61190fa..ff615d14 100644 --- a/internal/match/json.go +++ b/internal/match/json.go @@ -41,6 +41,18 @@ func JSONKeyPresent(wantKey string) JSON { } } +// JSONKeyMissing returns a matcher which will check that `forbiddenKey` is not present in the JSON object. +// `forbiddenKey` can be nested, see https://godoc.org/github.com/tidwall/gjson#Get for details. +func JSONKeyMissing(forbiddenKey string) JSON { + return func(body []byte) error { + res := gjson.GetBytes(body, forbiddenKey) + if res.Exists() { + return fmt.Errorf("key '%s' present", forbiddenKey) + } + return nil + } +} + // JSONKeyTypeEqual returns a matcher which will check that `wantKey` is present and its value is of the type `wantType`. // `wantKey` can be nested, see https://godoc.org/github.com/tidwall/gjson#Get for details. func JSONKeyTypeEqual(wantKey string, wantType gjson.Type) JSON { diff --git a/tests/csapi/ignored_users_test.go b/tests/csapi/ignored_users_test.go new file mode 100644 index 00000000..f3b42d37 --- /dev/null +++ b/tests/csapi/ignored_users_test.go @@ -0,0 +1,105 @@ +// +build !dendrite_blacklist + +// Rationale for being included in Dendrite's blacklist: https://github.com/matrix-org/dendrite/issues/600 +package csapi_tests + +import ( + "net/url" + "testing" + + "github.com/tidwall/gjson" + + "github.com/matrix-org/complement/internal/b" + "github.com/matrix-org/complement/internal/client" + "github.com/matrix-org/complement/internal/match" + "github.com/matrix-org/complement/internal/must" +) + +// The Spec says here +// https://spec.matrix.org/v1.1/client-server-api/#server-behaviour-13 +// that +// > Servers must not send room invites from ignored users to clients. +// +// This is a regression test for +// https://github.com/matrix-org/synapse/issues/11506 +// to ensure that Synapse complies with this part of the spec. +func TestInviteFromIgnoredUsersDoesNotAppearInSync(t *testing.T) { + deployment := Deploy(t, b.BlueprintCleanHS) + defer deployment.Destroy(t) + alice := deployment.RegisterUser(t, "hs1", "alice", "sufficiently_long_password_alice") + bob := deployment.RegisterUser(t, "hs1", "bob", "sufficiently_long_password_bob") + chris := deployment.RegisterUser(t, "hs1", "chris", "sufficiently_long_password_chris") + + // Alice creates a room for herself. + publicRoom := alice.CreateRoom(t, map[string]interface{}{ + "preset": "public_chat", + }) + + // Alice waits to see the join event. + alice.SyncUntilTimelineHas( + t, publicRoom, func(ev gjson.Result) bool { + return ev.Get("type").Str == "m.room.member" && + ev.Get("state_key").Str == alice.UserID && + ev.Get("content.membership").Str == "join" + }, + ) + + // Alice ignores Bob. + alice.MustDoFunc( + t, + "PUT", + []string{"_matrix", "client", "r0", "user", alice.UserID, "account_data", "m.ignored_user_list"}, + client.WithJSONBody(t, map[string]interface{}{ + "ignored_users": map[string]interface{}{ + bob.UserID: map[string]interface{}{}, + }, + }), + ) + + // Alice waits to see that the ignore was successful. + sinceJoinedAndIgnored := alice.SyncUntilGlobalAccountDataHas( + t, + func(ev gjson.Result) bool { + t.Logf(ev.Raw + "\n") + return ev.Get("type").Str == "m.ignored_user_list" && + ev.Get("content.ignored_users."+client.GjsonEscape(bob.UserID)).Exists() + }, + ) + + // Bob invites Alice to a private room. + bobRoom := bob.CreateRoom(t, map[string]interface{}{ + "preset": "private_chat", + "invite": []string{alice.UserID}, + }) + + // So does Chris. + chrisRoom := chris.CreateRoom(t, map[string]interface{}{ + "preset": "private_chat", + "invite": []string{alice.UserID}, + }) + + // Alice waits until she's seen Chris's invite. + alice.SyncUntilInvitedTo(t, chrisRoom) + + // We re-request the sync with a `since` token. We should see Chris's invite, but not Bob's. + queryParams := url.Values{ + "since": {sinceJoinedAndIgnored}, + "timeout": {"0"}, + } + // Note: SyncUntil only runs its callback on array elements. I want to investigate an object. + // So let's make the HTTP request more directly. + response := alice.MustDoFunc( + t, + "GET", + []string{"_matrix", "client", "r0", "sync"}, + client.WithQueries(queryParams), + ) + bobRoomPath := "rooms.invite." + client.GjsonEscape(bobRoom) + chrisRoomPath := "rooms.invite." + client.GjsonEscape(chrisRoom) + must.MatchResponse(t, response, match.HTTPResponse{ + JSON: []match.JSON{ + match.JSONKeyMissing(bobRoomPath), + match.JSONKeyPresent(chrisRoomPath), + }, + }) +}