Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
- homeserver: Synapse
repo: element-hq/synapse
tags: synapse_blacklist
packages: ./tests/msc3874 ./tests/msc3902 ./tests/msc4306
packages: ./tests/msc3874 ./tests/msc3902 ./tests/msc4306 ./tests/msc4429

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests in this PR weren't actually running in CI 🤦 I've fixed that in 4cddfea.

I assume you meant 2b67137 but in any case, we don't really worry about updating this list.

It's already heavily out of sync with the list we run in the Synapse project, element-hq/synapse -> scripts-dev/complement.sh#L272-L289

At this point, I think we just rely on it as a general sanity check that Complement is still working with Synapse.

env: "COMPLEMENT_ENABLE_DIRTY_RUNS=1 COMPLEMENT_SHARE_ENV_PREFIX=PASS_ PASS_SYNAPSE_COMPLEMENT_DATABASE=sqlite"
timeout: 20m

Expand Down
11 changes: 11 additions & 0 deletions tests/msc4429/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package tests

import (
"testing"

"github.com/matrix-org/complement"
)

func TestMain(m *testing.M) {
complement.TestMain(m, "msc4429")
}
305 changes: 305 additions & 0 deletions tests/msc4429/msc4429_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
package tests

import (
"encoding/json"
"fmt"
"testing"

"github.com/tidwall/gjson"

"github.com/matrix-org/complement"
"github.com/matrix-org/complement/client"
"github.com/matrix-org/complement/helpers"
"github.com/matrix-org/complement/match"
)

const (
// TODO: Support stable prefix once MSC4429 is accepted.
// msc4429UsersStable = "users"
msc4429UsersUnstable = "org\\.matrix\\.msc4429\\.users"
)

func TestMSC4429ProfileUpdates(t *testing.T) {
Comment thread
anoadragon453 marked this conversation as resolved.
deployment := complement.Deploy(t, 1)
defer deployment.Destroy(t)

t.Run("Initial sync includes requested profile fields and filters others", func(t *testing.T) {
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-initial"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-initial"})

mustCreateSharedRoom(t, alice, bob)

// Bob sets their displayname.
bob.MustSetDisplayName(t, "Bob Display")
// Bob sets their status.
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "busy",
"emoji": "🛑",
})

// Alice /sync's, but only asks for "m.status" changes.
// Exclude 'displayname'.
filter := mustBuildMSC4429Filter(t, []string{"m.status"})
res, _ := alice.MustSync(t, client.SyncReq{Filter: filter})
Comment thread
anoadragon453 marked this conversation as resolved.

// We should see the m.status profile update.
alice.MustSyncUntil(t, client.SyncReq{Filter: filter}, syncHasProfileUpdate(alice.UserID, "m.status", map[string]interface{}{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
alice.MustSyncUntil(t, client.SyncReq{Filter: filter}, syncHasProfileUpdate(alice.UserID, "m.status", map[string]interface{}{
alice.MustSyncUntil(t, client.SyncReq{Filter: filter}, syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{

This should check for bob's update, not alice's.

"text": "busy",
"emoji": "🛑",
}))

// We should NOT see a displayname profile update.
assertNoProfileUpdate(t, res, bob.UserID, "displayname")
})

// Receiving profile updates are an opt-in mechanism, according to MSC4429.
t.Run("No updates without profile_fields filter", func(t *testing.T) {
Comment thread
anoadragon453 marked this conversation as resolved.
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-nofilter"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-nofilter"})
charlie := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "charlie-nofilter"})

mustCreateSharedRoom(t, alice, bob, charlie)

// Perform an initial sync to get a since token.
_, since := alice.MustSync(t, client.SyncReq{})

// Bob sets their status.
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "away",
"emoji": "🟡",
})
Comment thread
anoadragon453 marked this conversation as resolved.

// Assert that charlie receives bob's profile update in an incremental sync
// with the appropriate filter set.
filter := mustBuildMSC4429Filter(t, []string{"m.status"})
charlie.MustSyncUntil(t, client.SyncReq{Filter: filter}, syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{
"text": "away",
"emoji": "🟡",
}))
Comment on lines +72 to +78

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kinda feels like this should also be an incremental sync to better demonstrate the data is there with the right filter.


// Assert that alice does not receive the profile update in an incremental sync.
res, _ := alice.MustSync(t, client.SyncReq{Since: since})
assertNoProfileUpdate(t, res, bob.UserID, "m.status")
})

// Check that only the latest update is returned per-user per-field.
t.Run("Incremental sync returns the latest update", func(t *testing.T) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we should have another test to make sure more subsequent incremental /sync include new updates for the new user.

This ensures that any caching (remembering which users were sent down before) is correct.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, we kinda have this already with "Cleared profile field is returned as null"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess, we kinda have this already with "Cleared profile field is returned as null"

Sort of. The data structure of the /sync response JSON doesn't allow to return two subsequent status updates for the same user and field:

{
  "users": {
    "@user:example.org": {
      "profile_updates": {
        "m.status": {
          "text": "Swimming in the Great Lakes!",
          "emoji": "🏊️"
        }
      }
    }
  }
}

What might be useful is an additional test with:

  • Bob updating their m.status field
  • Alice syncing without a sync filter which includes the field
  • Bob updating their displayname field
  • Alice syncing with a m.status and displayname filter
  • Alice should only receive the displayname update

alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-latest"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-latest"})

mustCreateSharedRoom(t, alice, bob)

filter := mustBuildMSC4429Filter(t, []string{"m.status"})
_, since := alice.MustSync(t, client.SyncReq{Filter: filter})

mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "first",
})
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "second",
})

alice.MustSyncUntil(
t,
client.SyncReq{Since: since, Filter: filter},
syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{
"text": "second",
}),
)
})

// Test that the homeserver informs the client when a profile field is cleared.
t.Run("Cleared profile field is returned as null", func(t *testing.T) {
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-clear"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-clear"})

mustCreateSharedRoom(t, alice, bob)

filter := mustBuildMSC4429Filter(t, []string{"m.status"})
_, since := alice.MustSync(t, client.SyncReq{Filter: filter})

// Bob sets a status.
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "busy",
})

// Wait until alice can see the status.
since = alice.MustSyncUntil(
t,
client.SyncReq{Since: since, Filter: filter},
syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{
"text": "busy",
}),
)

// Bob clears their status.
mustSetProfileField(t, bob, "m.status", nil)

// Wait until alice sees the status be set to `null` (nil).
alice.MustSyncUntil(
t,
client.SyncReq{Since: since, Filter: filter},
syncHasProfileUpdate(bob.UserID, "m.status", nil),
)
})

t.Run("A user leaving the last shared room returns a profile update of null", func(t *testing.T) {
alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-leave"})
bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-leave"})

roomID := mustCreateSharedRoom(t, alice, bob)

filter := mustBuildMSC4429Filter(t, []string{"m.status"})
since := alice.MustSyncUntil(
t,
client.SyncReq{Filter: filter},
client.SyncJoinedTo(alice.UserID, roomID),
)

// Bob sets a status while Alice and Bob share a room.
mustSetProfileField(t, bob, "m.status", map[string]interface{}{
"text": "busy",
})

// Alice receives Bob's changed profile field.
since = alice.MustSyncUntil(
t,
client.SyncReq{Since: since, Filter: filter},
syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{
"text": "busy",
}),
)

// Bob leaves the only room shared with Alice.
bob.MustLeaveRoom(t, roomID)

// Alice receives a null profile_updates value for Bob. This tells
// clients to clear their local cache for Bob's profile.
alice.MustSyncUntil(
t,
client.SyncReq{Since: since, Filter: filter},
// Check that bob left the room and we get a null profile field for them.
//
// `MustSyncUntil` will loop until both checks are true. That
// doesn't necessarily have to happen in the same sync response.
client.SyncLeftFrom(bob.UserID, roomID),
syncHasProfileUpdatesNull(bob.UserID),
)
})
}

// mustBuildMSC4429Filter builds a filter that can be used to limit the field
// IDs returned in a `/sync` response.
func mustBuildMSC4429Filter(t *testing.T, ids []string) string {
t.Helper()
filter := map[string]interface{}{
"profile_fields": map[string]interface{}{
"ids": ids,
},
"org.matrix.msc4429.profile_fields": map[string]interface{}{
"ids": ids,
},
}
encoded, err := json.Marshal(filter)
if err != nil {
t.Fatalf("failed to marshal MSC4429 filter: %s", err)
}
return string(encoded)
}

// mustCreateSharedRoom creates a shared room between `alice` and `bob` and returns the
// room ID.
func mustCreateSharedRoom(t *testing.T, users ...*client.CSAPI) string {
t.Helper()

// Use one of the users to create the room.
roomID := users[0].MustCreateRoom(t, map[string]interface{}{
"preset": "public_chat",
})

// Join all of the given users to the room.
for _, user := range users {
user.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(user.UserID, roomID))
user.MustJoinRoom(t, roomID, nil)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be before the user.MustSyncUntil afaict.

}

return roomID
}

// mustSetProfileField sets the given profile field ID to the given value on the given user's
// profile.
func mustSetProfileField(t *testing.T, user *client.CSAPI, field string, value interface{}) {
t.Helper()
user.MustDo(t, "PUT", []string{"_matrix", "client", "v3", "profile", user.UserID, field},
client.WithJSONBody(t, map[string]interface{}{
field: value,
}),
)
}

// getProfileUpdate extracts the given profile updates for a given user by field
// ID from a legacy `/sync` response.
func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, bool) {
unstablePath := msc4429UsersUnstable + "." + client.GjsonEscape(userID) + ".profile_updates." + client.GjsonEscape(field)
unstableRes := res.Get(unstablePath)
if unstableRes.Exists() {
return unstableRes, true
}
return gjson.Result{}, false
}

// getProfileUpdates extracts all profile updates for a given user from a legacy
// `/sync` response.
func getProfileUpdates(res gjson.Result, userID string) (gjson.Result, bool) {
unstablePath := msc4429UsersUnstable + "." + client.GjsonEscape(userID) + ".profile_updates"
unstableRes := res.Get(unstablePath)
if unstableRes.Exists() {
return unstableRes, true
}
return gjson.Result{}, false
}

// assertNoProfileUpdate asserts that a user has not updated a field of their
// profile in the given legacy /sync response JSON.
func assertNoProfileUpdate(t *testing.T, res gjson.Result, userID, field string) {
t.Helper()
if update, ok := getProfileUpdate(res, userID, field); ok {
t.Fatalf("expected no profile update for %s %s: %s", userID, field, update.Raw)
}
}

// syncHasProfileUpdate checks whether a given sync response contains a profile
// update of the given, expected field and value.
func syncHasProfileUpdate(userID, field string, expected interface{}) client.SyncCheckOpt {
return func(clientUserID string, topLevelSyncJSON gjson.Result) error {
update, ok := getProfileUpdate(topLevelSyncJSON, userID, field)
if !ok {
return fmt.Errorf("missing profile update for %s %s", userID, field)
}
if expected == nil {
if update.Type != gjson.Null {
return fmt.Errorf("expected null profile update for %s %s, got %s", userID, field, update.Type)
}
return nil
}
if err := match.JSONKeyEqual("", expected)(update); err != nil {
return fmt.Errorf("profile update mismatch for %s %s: %w", userID, field, err)
}
return nil
}
}

// syncHasProfileUpdatesNull checks whether a sync response contains a null
// profile_updates value for the given user.
func syncHasProfileUpdatesNull(userID string) client.SyncCheckOpt {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the usage could be replaced by syncHasProfileUpdate(...)

Or at-least this function re-uses syncHasProfileUpdate(...)

return func(clientUserID string, topLevelSyncJSON gjson.Result) error {
updates, ok := getProfileUpdates(topLevelSyncJSON, userID)
if !ok {
return fmt.Errorf("missing profile updates for %s", userID)
}
if updates.Type != gjson.Null {
return fmt.Errorf("expected a null profile update for %s, got %s", userID, updates.Type)
}
return nil
}
}
Loading