From c32d2c478d67715b0e52a5ca469149c720763700 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Thu, 12 Mar 2026 13:56:27 -0400 Subject: [PATCH 01/12] Initial MSC4429 tests --- tests/msc4429/main_test.go | 11 ++ tests/msc4429/msc4429_test.go | 207 ++++++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 tests/msc4429/main_test.go create mode 100644 tests/msc4429/msc4429_test.go diff --git a/tests/msc4429/main_test.go b/tests/msc4429/main_test.go new file mode 100644 index 000000000..29daa335c --- /dev/null +++ b/tests/msc4429/main_test.go @@ -0,0 +1,11 @@ +package tests + +import ( + "testing" + + "github.com/matrix-org/complement" +) + +func TestMain(m *testing.M) { + complement.TestMain(m, "msc4429") +} diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go new file mode 100644 index 000000000..3ee6bf9d0 --- /dev/null +++ b/tests/msc4429/msc4429_test.go @@ -0,0 +1,207 @@ +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" + "github.com/matrix-org/complement/must" +) + +const ( + msc4429UsersStable = "users" + msc4429UsersUnstable = "org\\.matrix\\.msc4429\\.users" +) + +func TestMSC4429ProfileUpdates(t *testing.T) { + 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.MustSetDisplayName(t, "Bob Display") + mustSetProfileField(t, bob, "m.status", map[string]interface{}{ + "text": "busy", + "emoji": "🛑", + }) + + // Exclude 'displayname' + filter := mustBuildMSC4429Filter(t, []string{"m.status"}) + res, _ := alice.MustSync(t, client.SyncReq{Filter: filter}) + + update, ok := getProfileUpdate(res, bob.UserID, "m.status") + if !ok { + t.Fatalf("missing m.status profile update for %s in initial sync: %s", bob.UserID, res.Raw) + } + must.MatchGJSON(t, update, match.JSONKeyEqual("", map[string]interface{}{ + "text": "busy", + "emoji": "🛑", + })) + assertNoProfileUpdate(t, res, bob.UserID, "displayname") + }) + + t.Run("No updates without profile_fields filter", func(t *testing.T) { + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-nofilter"}) + bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-nofilter"}) + + mustCreateSharedRoom(t, alice, bob) + + // No filter = no profile fields returned. + _, since := alice.MustSync(t, client.SyncReq{}) + mustSetProfileField(t, bob, "m.status", map[string]interface{}{ + "text": "away", + }) + + res, _ := alice.MustSync(t, client.SyncReq{Since: since}) + assertNoProfileUpdate(t, res, bob.UserID, "m.status") + }) + + t.Run("Incremental sync returns the latest update", func(t *testing.T) { + 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", + }), + ) + }) + + 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}) + + mustSetProfileField(t, bob, "m.status", map[string]interface{}{ + "text": "busy", + }) + since = alice.MustSyncUntil( + t, + client.SyncReq{Since: since, Filter: filter}, + syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{ + "text": "busy", + }), + ) + + mustSetProfileField(t, bob, "m.status", nil) + alice.MustSyncUntil( + t, + client.SyncReq{Since: since, Filter: filter}, + syncHasProfileUpdate(bob.UserID, "m.status", nil), + ) + }) +} + +// 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, alice *client.CSAPI, bob *client.CSAPI) string { + t.Helper() + roomID := alice.MustCreateRoom(t, map[string]interface{}{ + "preset": "public_chat", + }) + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID)) + bob.MustJoinRoom(t, roomID, nil) + alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + 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) { + stablePath := msc4429UsersStable + "." + client.GjsonEscape(userID) + ".profile_updates." + client.GjsonEscape(field) + stableRes := res.Get(stablePath) + if stableRes.Exists() { + return stableRes, true + } + unstablePath := msc4429UsersUnstable + "." + client.GjsonEscape(userID) + ".profile_updates." + client.GjsonEscape(field) + unstableRes := res.Get(unstablePath) + if unstableRes.Exists() { + return unstableRes, true + } + return gjson.Result{}, false +} + +func assertNoProfileUpdate(t *testing.T, res gjson.Result, userID, field string) { + t.Helper() + if update, ok := getProfileUpdate(res, userID, field); ok { + t.Fatalf("unexpected profile update for %s %s: %s", userID, field, update.Raw) + } +} + +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 + } +} From 58fbe50935525d346d635d98e156cfdac2e08379 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 10:01:59 +0100 Subject: [PATCH 02/12] Comment out stable prefix support for now --- tests/msc4429/msc4429_test.go | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 3ee6bf9d0..f7cb9ce74 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -15,7 +15,8 @@ import ( ) const ( - msc4429UsersStable = "users" + // TODO: Support stable prefix once MSC4429 is accepted. + // msc4429UsersStable = "users" msc4429UsersUnstable = "org\\.matrix\\.msc4429\\.users" ) @@ -167,11 +168,6 @@ func mustSetProfileField(t *testing.T, user *client.CSAPI, field string, value i // 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) { - stablePath := msc4429UsersStable + "." + client.GjsonEscape(userID) + ".profile_updates." + client.GjsonEscape(field) - stableRes := res.Get(stablePath) - if stableRes.Exists() { - return stableRes, true - } unstablePath := msc4429UsersUnstable + "." + client.GjsonEscape(userID) + ".profile_updates." + client.GjsonEscape(field) unstableRes := res.Get(unstablePath) if unstableRes.Exists() { From 543b1135486800cc0524c70ed2bb2d0acc189904 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 10:56:50 +0100 Subject: [PATCH 03/12] Use MustSyncUntil in initial sync test Add various comments throughout for clarification. --- tests/msc4429/msc4429_test.go | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index f7cb9ce74..9336ca143 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -11,7 +11,6 @@ import ( "github.com/matrix-org/complement/client" "github.com/matrix-org/complement/helpers" "github.com/matrix-org/complement/match" - "github.com/matrix-org/complement/must" ) const ( @@ -30,27 +29,30 @@ func TestMSC4429ProfileUpdates(t *testing.T) { 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": "🛑", }) - // Exclude 'displayname' + // 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}) - update, ok := getProfileUpdate(res, bob.UserID, "m.status") - if !ok { - t.Fatalf("missing m.status profile update for %s in initial sync: %s", bob.UserID, res.Raw) - } - must.MatchGJSON(t, update, match.JSONKeyEqual("", map[string]interface{}{ + // We should see the m.status profile update. + alice.MustSyncUntil(t, client.SyncReq{Filter: filter}, syncHasProfileUpdate(alice.UserID, "m.status", map[string]interface{}{ "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) { alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-nofilter"}) bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-nofilter"}) @@ -59,14 +61,18 @@ func TestMSC4429ProfileUpdates(t *testing.T) { // No filter = no profile fields returned. _, since := alice.MustSync(t, client.SyncReq{}) + + // Bob sets their status. mustSetProfileField(t, bob, "m.status", map[string]interface{}{ "text": "away", }) + // Assert that alice does not receive it. 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) { alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-latest"}) bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-latest"}) @@ -92,6 +98,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { ) }) + // 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"}) @@ -101,9 +108,12 @@ func TestMSC4429ProfileUpdates(t *testing.T) { 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}, @@ -112,7 +122,10 @@ func TestMSC4429ProfileUpdates(t *testing.T) { }), ) + // 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}, @@ -176,6 +189,8 @@ func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, boo 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 { @@ -183,6 +198,8 @@ func assertNoProfileUpdate(t *testing.T, res gjson.Result, userID, field string) } } +// 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) From 2a89f7d16861089b77ee12d6d4cbb363f254e25a Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 11:02:33 +0100 Subject: [PATCH 04/12] Clarify why we perform an initial sync --- tests/msc4429/msc4429_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 9336ca143..556454557 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -59,7 +59,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { mustCreateSharedRoom(t, alice, bob) - // No filter = no profile fields returned. + // Perform an initial sync to get a since token. _, since := alice.MustSync(t, client.SyncReq{}) // Bob sets their status. @@ -67,7 +67,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { "text": "away", }) - // Assert that alice does not receive it. + // Assert that alice does not receive it in an incremental sync. res, _ := alice.MustSync(t, client.SyncReq{Since: since}) assertNoProfileUpdate(t, res, bob.UserID, "m.status") }) From 5ea104f969429316dfdb5fe7a52e1cc7e69978b7 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 11:23:16 +0100 Subject: [PATCH 05/12] Assert third user can see status update --- tests/msc4429/msc4429_test.go | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 556454557..68a4940ca 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -56,8 +56,9 @@ func TestMSC4429ProfileUpdates(t *testing.T) { t.Run("No updates without profile_fields filter", func(t *testing.T) { 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) + mustCreateSharedRoom(t, alice, bob, charlie) // Perform an initial sync to get a since token. _, since := alice.MustSync(t, client.SyncReq{}) @@ -65,9 +66,18 @@ func TestMSC4429ProfileUpdates(t *testing.T) { // Bob sets their status. mustSetProfileField(t, bob, "m.status", map[string]interface{}{ "text": "away", + "emoji": "🟡", }) - // Assert that alice does not receive it in an incremental sync. + // 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": "🟡", + })) + + // 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") }) @@ -155,15 +165,20 @@ func mustBuildMSC4429Filter(t *testing.T, ids []string) string { // mustCreateSharedRoom creates a shared room between `alice` and `bob` and returns the // room ID. -func mustCreateSharedRoom(t *testing.T, alice *client.CSAPI, bob *client.CSAPI) string { +func mustCreateSharedRoom(t *testing.T, users ...*client.CSAPI) string { t.Helper() - roomID := alice.MustCreateRoom(t, map[string]interface{}{ + + // Use one of the users to create the room. + roomID := users[0].MustCreateRoom(t, map[string]interface{}{ "preset": "public_chat", }) - alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID)) - bob.MustJoinRoom(t, roomID, nil) - alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) - bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID)) + + // 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) + } + return roomID } From a1934aca56123b3e94a8c1ca710f6a4b17d4a510 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 11:39:29 +0100 Subject: [PATCH 06/12] Clarify assertNoprofileUpdate error message --- tests/msc4429/msc4429_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 68a4940ca..008176903 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -209,7 +209,7 @@ func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, boo func assertNoProfileUpdate(t *testing.T, res gjson.Result, userID, field string) { t.Helper() if update, ok := getProfileUpdate(res, userID, field); ok { - t.Fatalf("unexpected profile update for %s %s: %s", userID, field, update.Raw) + t.Fatalf("expected no profile update for %s %s: %s", userID, field, update.Raw) } } From 4cddfeab619504b8ed4a9c86df3892953cb63c75 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 14:34:46 +0100 Subject: [PATCH 07/12] Add test for receiving `null` profile update when users no longer share a room --- tests/msc4429/msc4429_test.go | 72 ++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 008176903..857299fbb 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -65,7 +65,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { // Bob sets their status. mustSetProfileField(t, bob, "m.status", map[string]interface{}{ - "text": "away", + "text": "away", "emoji": "🟡", }) @@ -142,6 +142,50 @@ func TestMSC4429ProfileUpdates(t *testing.T) { 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 @@ -204,6 +248,17 @@ func getProfileUpdate(res gjson.Result, userID, field string) (gjson.Result, boo 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) { @@ -233,3 +288,18 @@ func syncHasProfileUpdate(userID, field string, expected interface{}) client.Syn return nil } } + +// syncHasProfileUpdatesNull checks whether a sync response contains a null +// profile_updates value for the given user. +func syncHasProfileUpdatesNull(userID string) client.SyncCheckOpt { + 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 + } +} From 2b67137ea2b3c495584c231b6313c7389ec6b4b8 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Tue, 23 Jun 2026 15:10:28 +0100 Subject: [PATCH 08/12] Add MSC4429 tests to Synapse in CI --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 528d0f21c..210bf5e33 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 env: "COMPLEMENT_ENABLE_DIRTY_RUNS=1 COMPLEMENT_SHARE_ENV_PREFIX=PASS_ PASS_SYNAPSE_COMPLEMENT_DATABASE=sqlite" timeout: 20m From 3b4d4f7cd677497f7d3de3edc3a80fd9b85aa05b Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 24 Jun 2026 09:31:47 +0100 Subject: [PATCH 09/12] Fix ordering of joining vs syncing --- tests/msc4429/msc4429_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 857299fbb..0d7de2f3b 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -219,8 +219,8 @@ func mustCreateSharedRoom(t *testing.T, users ...*client.CSAPI) string { // 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) + user.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(user.UserID, roomID)) } return roomID From bf3cbef54ac68e6c748a25eb41aef08ff86996c6 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 24 Jun 2026 09:33:00 +0100 Subject: [PATCH 10/12] alice <-> bob `m.status` checking --- tests/msc4429/msc4429_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 0d7de2f3b..d3aa78eb3 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -43,7 +43,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { res, _ := alice.MustSync(t, client.SyncReq{Filter: filter}) // We should see the m.status profile update. - 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{}{ "text": "busy", "emoji": "🛑", })) From 2f701517a4b69a97df377767c938af61abacb0df Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 24 Jun 2026 09:35:34 +0100 Subject: [PATCH 11/12] Make an incremental sync to get charlie's status update --- tests/msc4429/msc4429_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index d3aa78eb3..613e4fa8c 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -72,7 +72,7 @@ func TestMSC4429ProfileUpdates(t *testing.T) { // 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{}{ + charlie.MustSyncUntil(t, client.SyncReq{Filter: filter, Since: since}, syncHasProfileUpdate(bob.UserID, "m.status", map[string]interface{}{ "text": "away", "emoji": "🟡", })) From 897e7ab59d3911942cfb5c8648328a0105932dd0 Mon Sep 17 00:00:00 2001 From: Andrew Morgan Date: Wed, 24 Jun 2026 11:56:41 +0100 Subject: [PATCH 12/12] Add test to check that widening the sync filter does not return old updates To test that caching functionality in the homeserver does not result in old profile updates being sent down erroneously. --- tests/msc4429/msc4429_test.go | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/tests/msc4429/msc4429_test.go b/tests/msc4429/msc4429_test.go index 613e4fa8c..8cd318ce1 100644 --- a/tests/msc4429/msc4429_test.go +++ b/tests/msc4429/msc4429_test.go @@ -82,6 +82,45 @@ func TestMSC4429ProfileUpdates(t *testing.T) { assertNoProfileUpdate(t, res, bob.UserID, "m.status") }) + t.Run("Widening profile_fields filter does not return old filtered updates", func(t *testing.T) { + alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-widen"}) + bob := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "bob-widen"}) + + mustCreateSharedRoom(t, alice, bob) + + madeUpProfileField := "complement.made-up-profile-field" + + // Get a sync token for alice. + _, since := alice.MustSync(t, client.SyncReq{}) + + // Bob updates their profile with two fields. + mustSetProfileField(t, bob, madeUpProfileField, "foo") + mustSetProfileField(t, bob, "m.status", map[string]interface{}{ + "text": "away", + }) + + // Alice advances their sync token requesting just one of the profile fields. + // Widening the filter later must not make this old status update appear. + filter := mustBuildMSC4429Filter(t, []string{madeUpProfileField}) + // We should have only received the `complement.made-up-profile-field` field. + since = alice.MustSyncUntil( + t, + client.SyncReq{Since: since, Filter: filter}, + syncHasProfileUpdateWithoutFields(bob.UserID, madeUpProfileField, "foo", "m.status"), + ) + + // Bob updates a third profile field. + bob.MustSetDisplayName(t, "Bob Widened") + + // Alice should only receive the `displayname` update.` + filter = mustBuildMSC4429Filter(t, []string{madeUpProfileField, "m.status", "displayname"}) + alice.MustSyncUntil( + t, + client.SyncReq{Since: since, Filter: filter}, + syncHasProfileUpdateWithoutFields(bob.UserID, "displayname", "Bob Widened", "m.status", madeUpProfileField), + ) + }) + // Check that only the latest update is returned per-user per-field. t.Run("Incremental sync returns the latest update", func(t *testing.T) { alice := deployment.Register(t, "hs1", helpers.RegistrationOpts{LocalpartSuffix: "alice-latest"}) @@ -289,6 +328,27 @@ func syncHasProfileUpdate(userID, field string, expected interface{}) client.Syn } } +// syncHasProfileUpdateWithoutField checks whether a sync response contains a +// profile update for one field and does not contain another field for the same +// user. +func syncHasProfileUpdateWithoutFields(userID, field string, expected interface{}, withoutFields ...string) client.SyncCheckOpt { + return func(clientUserID string, topLevelSyncJSON gjson.Result) error { + // Check that we have one field. + if err := syncHasProfileUpdate(userID, field, expected)(clientUserID, topLevelSyncJSON); err != nil { + return err + } + + // But not the `withoutField` fields. + for _, fieldName := range withoutFields { + if update, ok := getProfileUpdate(topLevelSyncJSON, userID, fieldName); ok { + return fmt.Errorf("unexpected profile update for %s %s: %s", userID, fieldName, update.Raw) + } + } + + return nil + } +} + // syncHasProfileUpdatesNull checks whether a sync response contains a null // profile_updates value for the given user. func syncHasProfileUpdatesNull(userID string) client.SyncCheckOpt {