Skip to content

Commit

Permalink
Add more thorough tests for device list tracking (#459)
Browse files Browse the repository at this point in the history
Test what happens when local and remote users join and leave rooms.
The previous test only covered local users already in the same room.

Signed-off-by: Sean Quah <[email protected]>
  • Loading branch information
squahtx authored Sep 13, 2022
1 parent 0091cd2 commit 4acae02
Showing 1 changed file with 318 additions and 48 deletions.
366 changes: 318 additions & 48 deletions tests/csapi/device_lists_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,61 +6,331 @@ import (

"github.com/matrix-org/complement/internal/b"
"github.com/matrix-org/complement/internal/client"
"github.com/matrix-org/complement/internal/docker"
"github.com/matrix-org/complement/internal/match"
"github.com/matrix-org/complement/internal/must"
"github.com/matrix-org/complement/runtime"
"maunium.net/go/mautrix/crypto/olm"

"github.com/tidwall/gjson"
)

// TestLocalUsersReceiveDeviceListUpdates tests that users on the same
// homeserver receive device list updates from other local users, as
// long as they share a room.
func TestLocalUsersReceiveDeviceListUpdates(t *testing.T) {
// Create a homeserver with two users that share a room
deployment := Deploy(t, b.BlueprintOneToOneRoom)
defer deployment.Destroy(t)
// TestDeviceListUpdates tests various flows and checks that:
// 1. `/sync`'s `device_lists.changed/left` contain the correct user IDs.
// 2. `/keys/query` returns the correct information after device list updates.
func TestDeviceListUpdates(t *testing.T) {
localpartIndex := 0
// generateLocalpart generates a unique localpart based on the given name.
generateLocalpart := func(localpart string) string {
localpartIndex++
return fmt.Sprintf("%s%d", localpart, localpartIndex)
}

// Get a reference to the already logged-in CS API clients for each user
alice := deployment.Client(t, "hs1", "@alice:hs1")
bob := deployment.Client(t, "hs1", "@bob:hs1")

// Deduce alice's device ID
resp := alice.MustDoFunc(
t,
"GET",
[]string{"_matrix", "client", "v3", "account", "whoami"},
)
responseBodyBytes := client.ParseJSON(t, resp)
aliceDeviceID := gjson.GetBytes(responseBodyBytes, "device_id").Str

// Bob performs an initial sync
_, bobNextBatch := bob.MustSync(t, client.SyncReq{})

// Alice then updates their device list by renaming their current device
alice.MustDoFunc(
t,
"PUT",
[]string{"_matrix", "client", "v3", "devices", aliceDeviceID},
client.WithJSONBody(
t,
map[string]interface{}{
"display_name": "A New Device Name",
},
),
)

// Check that Bob received a device list update from Alice
bob.MustSyncUntil(
t,
client.SyncReq{
Since: bobNextBatch,
}, func(clientUserID string, topLevelSyncJSON gjson.Result) error {
// Ensure that Bob sees that Alice has updated their device list
usersWithChangedDeviceListsArray := topLevelSyncJSON.Get("device_lists.changed").Array()
// uploadNewKeys uploads a new set of keys for a given client.
// Returns a check function that can be passed to mustQueryKeys.
uploadNewKeys := func(t *testing.T, user *client.CSAPI) []match.JSON {
t.Helper()

account := olm.NewAccount()
ed25519Key, curve25519Key := account.IdentityKeys()

ed25519KeyID := fmt.Sprintf("ed25519:%s", user.DeviceID)
curve25519KeyID := fmt.Sprintf("curve25519:%s", user.DeviceID)

user.MustDoFunc(t, "POST", []string{"_matrix", "client", "v3", "keys", "upload"},
client.WithJSONBody(t, map[string]interface{}{
"device_keys": map[string]interface{}{
"user_id": user.UserID,
"device_id": user.DeviceID,
"algorithms": []interface{}{"m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"},
"keys": map[string]interface{}{
ed25519KeyID: ed25519Key.String(),
curve25519KeyID: curve25519Key.String(),
},
},
}),
)

algorithmsPath := fmt.Sprintf("device_keys.%s.%s.algorithms", user.UserID, user.DeviceID)
ed25519Path := fmt.Sprintf("device_keys.%s.%s.keys.%s", user.UserID, user.DeviceID, ed25519KeyID)
curve25519Path := fmt.Sprintf("device_keys.%s.%s.keys.%s", user.UserID, user.DeviceID, curve25519KeyID)
return []match.JSON{
match.JSONKeyEqual(algorithmsPath, []interface{}{"m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"}),
match.JSONKeyEqual(ed25519Path, ed25519Key.String()),
match.JSONKeyEqual(curve25519Path, curve25519Key.String()),
}
}

// mustQueryKeys checks that /keys/query returns the correct device keys.
// Accepts a check function produced by a prior call to uploadNewKeys.
mustQueryKeys := func(t *testing.T, user *client.CSAPI, userID string, check []match.JSON) {
t.Helper()

res := user.DoFunc(t, "POST", []string{"_matrix", "client", "v3", "keys", "query"},
client.WithJSONBody(t, map[string]interface{}{
"device_keys": map[string]interface{}{
userID: []string{},
},
}),
)
must.MatchResponse(t, res, match.HTTPResponse{
StatusCode: 200,
JSON: check,
})
}

// syncDeviceListsHas checks that `device_lists.changed` or `device_lists.left` contains a given
// user ID.
syncDeviceListsHas := func(section string, expectedUserID string) client.SyncCheckOpt {
jsonPath := fmt.Sprintf("device_lists.%s", section)
return func(clientUserID string, topLevelSyncJSON gjson.Result) error {
usersWithChangedDeviceListsArray := topLevelSyncJSON.Get(jsonPath).Array()
for _, userID := range usersWithChangedDeviceListsArray {
if userID.Str == alice.UserID {
if userID.Str == expectedUserID {
return nil
}
}
return fmt.Errorf("missing device list update for Alice")
},
)
return fmt.Errorf(
"syncDeviceListsHas: %s not found in %s",
expectedUserID,
jsonPath,
)
}
}

// In all of these test scenarios, there are two users: Alice and Bob.
// We only care about what Alice sees.

// testOtherUserJoin tests another user joining a room Alice is already in.
testOtherUserJoin := func(t *testing.T, deployment *docker.Deployment, hsName string, otherHSName string) {
alice := deployment.RegisterUser(t, hsName, generateLocalpart("alice"), "password", false)
bob := deployment.RegisterUser(t, otherHSName, generateLocalpart("bob"), "password", false)
checkBobKeys := uploadNewKeys(t, bob)

roomID := alice.CreateRoom(t, map[string]interface{}{"preset": "public_chat"})

// Alice performs an initial sync
_, aliceNextBatch := alice.MustSync(t, client.SyncReq{})

// Bob joins the room
bob.JoinRoom(t, roomID, []string{hsName})
bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))

// Check that Alice receives a device list update from Bob
alice.MustSyncUntil(
t,
client.SyncReq{Since: aliceNextBatch},
syncDeviceListsHas("changed", bob.UserID),
)
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)
// Some homeservers (Synapse) may emit another `changed` update after querying keys.
_, aliceNextBatch = alice.MustSync(t, client.SyncReq{TimeoutMillis: "0"})

// Both homeservers think Bob has joined now
// Bob then updates their device list
checkBobKeys = uploadNewKeys(t, bob)

// Check that Alice receives a device list update from Bob
alice.MustSyncUntil(
t,
client.SyncReq{Since: aliceNextBatch},
syncDeviceListsHas("changed", bob.UserID),
)
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)
}

// testJoin tests Alice joining a room another user is already in.
testJoin := func(
t *testing.T, deployment *docker.Deployment, hsName string, otherHSName string,
) {
alice := deployment.RegisterUser(t, hsName, generateLocalpart("alice"), "password", false)
bob := deployment.RegisterUser(t, otherHSName, generateLocalpart("bob"), "password", false)
checkBobKeys := uploadNewKeys(t, bob)

roomID := bob.CreateRoom(t, map[string]interface{}{"preset": "public_chat"})

// Alice performs an initial sync
_, aliceNextBatch := alice.MustSync(t, client.SyncReq{})

// Alice joins the room
alice.JoinRoom(t, roomID, []string{otherHSName})
bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))

// Check that Alice receives a device list update from Bob
alice.MustSyncUntil(
t,
client.SyncReq{Since: aliceNextBatch},
syncDeviceListsHas("changed", bob.UserID),
)
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)
// Some homeservers (Synapse) may emit another `changed` update after querying keys.
_, aliceNextBatch = alice.MustSync(t, client.SyncReq{TimeoutMillis: "0"})

// Both homeservers think Alice has joined now
// Bob then updates their device list
checkBobKeys = uploadNewKeys(t, bob)

// Check that Alice receives a device list update from Bob
alice.MustSyncUntil(
t,
client.SyncReq{Since: aliceNextBatch},
syncDeviceListsHas("changed", bob.UserID),
)
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)
}

// testOtherUserLeave tests another user leaving a room Alice is in.
testOtherUserLeave := func(t *testing.T, deployment *docker.Deployment, hsName string, otherHSName string) {
alice := deployment.RegisterUser(t, hsName, generateLocalpart("alice"), "password", false)
bob := deployment.RegisterUser(t, otherHSName, generateLocalpart("bob"), "password", false)
checkBobKeys := uploadNewKeys(t, bob)

roomID := alice.CreateRoom(t, map[string]interface{}{"preset": "public_chat"})

// Bob joins the room
bob.JoinRoom(t, roomID, []string{hsName})
bobNextBatch := bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))

// Alice performs an initial sync
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)
// Some homeservers (Synapse) may emit another `changed` update after querying keys.
_, aliceNextBatch := alice.MustSync(t, client.SyncReq{TimeoutMillis: "0"})

// Bob leaves the room
bob.LeaveRoom(t, roomID)
bob.MustSyncUntil(t, client.SyncReq{Since: bobNextBatch}, client.SyncLeftFrom(bob.UserID, roomID))

// Check that Alice is notified that she will no longer receive updates about Bob's devices
aliceNextBatch = alice.MustSyncUntil(
t,
client.SyncReq{Since: aliceNextBatch},
syncDeviceListsHas("left", bob.UserID),
)

// Both homeservers think Bob has left now
// Bob then updates their device list
// Alice's homeserver is not expected to get the device list update and must not return a
// cached device list for Bob.
checkBobKeys = uploadNewKeys(t, bob)
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)

// Check that Alice is not notified about Bob's device update
syncResult, _ := alice.MustSync(t, client.SyncReq{Since: aliceNextBatch})
if syncDeviceListsHas("changed", bob.UserID)(alice.UserID, syncResult) == nil {
t.Fatalf("Alice was unexpectedly notified about Bob's device update even though they share no rooms")
}
}

// testLeave tests Alice leaving a room another user is in.
testLeave := func(t *testing.T, deployment *docker.Deployment, hsName string, otherHSName string) {
alice := deployment.RegisterUser(t, hsName, generateLocalpart("alice"), "password", false)
bob := deployment.RegisterUser(t, otherHSName, generateLocalpart("bob"), "password", false)
checkBobKeys := uploadNewKeys(t, bob)

roomID := bob.CreateRoom(t, map[string]interface{}{"preset": "public_chat"})

// Alice joins the room
alice.JoinRoom(t, roomID, []string{otherHSName})
bobNextBatch := bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))

// Alice performs an initial sync
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(alice.UserID, roomID))
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)
// Some homeservers (Synapse) may emit another `changed` update after querying keys.
_, aliceNextBatch := alice.MustSync(t, client.SyncReq{TimeoutMillis: "0"})

// Alice leaves the room
alice.LeaveRoom(t, roomID)
bob.MustSyncUntil(t, client.SyncReq{Since: bobNextBatch}, client.SyncLeftFrom(alice.UserID, roomID))

// Check that Alice is notified that she will no longer receive updates about Bob's devices
aliceNextBatch = alice.MustSyncUntil(
t,
client.SyncReq{Since: aliceNextBatch},
syncDeviceListsHas("left", bob.UserID),
)

// Both homeservers think Alice has left now
// Bob then updates their device list
// Alice's homeserver is not expected to get the device list update and must not return a
// cached device list for Bob.
checkBobKeys = uploadNewKeys(t, bob)
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)

// Check that Alice is not notified about Bob's device update
syncResult, _ := alice.MustSync(t, client.SyncReq{Since: aliceNextBatch})
if syncDeviceListsHas("changed", bob.UserID)(alice.UserID, syncResult) == nil {
t.Fatalf("Alice was unexpectedly notified about Bob's device update even though they share no rooms")
}
}

// testOtherUserRejoin tests another user leaving and rejoining a room Alice is in.
testOtherUserRejoin := func(t *testing.T, deployment *docker.Deployment, hsName string, otherHSName string) {
alice := deployment.RegisterUser(t, hsName, generateLocalpart("alice"), "password", false)
bob := deployment.RegisterUser(t, otherHSName, generateLocalpart("bob"), "password", false)
checkBobKeys := uploadNewKeys(t, bob)

roomID := alice.CreateRoom(t, map[string]interface{}{"preset": "public_chat"})

// Bob joins the room
bob.JoinRoom(t, roomID, []string{hsName})
bobNextBatch := bob.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))

// Alice performs an initial sync
alice.MustSyncUntil(t, client.SyncReq{}, client.SyncJoinedTo(bob.UserID, roomID))
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)
// Some homeservers (Synapse) may emit another `changed` update after querying keys.
_, aliceNextBatch := alice.MustSync(t, client.SyncReq{TimeoutMillis: "0"})

// Both homeservers think Bob has joined now
// Bob leaves the room
bob.LeaveRoom(t, roomID)
bobNextBatch = bob.MustSyncUntil(t, client.SyncReq{Since: bobNextBatch}, client.SyncLeftFrom(bob.UserID, roomID))

// Check that Alice is notified that she will no longer receive updates about Bob's devices
alice.MustSyncUntil(
t,
client.SyncReq{Since: aliceNextBatch},
syncDeviceListsHas("left", bob.UserID),
)

// Both homeservers think Bob has left now
// Bob then updates their device list before rejoining the room
// Alice's homeserver is not expected to get the device list update.
checkBobKeys = uploadNewKeys(t, bob)

// Bob rejoins the room
bob.JoinRoom(t, roomID, []string{hsName})
bob.MustSyncUntil(t, client.SyncReq{Since: bobNextBatch}, client.SyncJoinedTo(bob.UserID, roomID))

// Check that Alice is notified that Bob's devices have a change
// Alice's homeserver must not return a cached device list for Bob.
alice.MustSyncUntil(
t,
client.SyncReq{Since: aliceNextBatch},
syncDeviceListsHas("changed", bob.UserID),
)
mustQueryKeys(t, alice, bob.UserID, checkBobKeys)
}

// Create two homeservers
// The users and rooms in the blueprint won't be used.
// Each test creates their own Alice and Bob users.
deployment := Deploy(t, b.BlueprintFederationOneToOneRoom)
defer deployment.Destroy(t)

t.Run("when local user joins a room", func(t *testing.T) { testOtherUserJoin(t, deployment, "hs1", "hs1") })
t.Run("when remote user joins a room", func(t *testing.T) { testOtherUserJoin(t, deployment, "hs1", "hs2") })
t.Run("when joining a room with a local user", func(t *testing.T) { testJoin(t, deployment, "hs1", "hs1") })
t.Run("when joining a room with a remote user", func(t *testing.T) { testJoin(t, deployment, "hs1", "hs2") })
t.Run("when local user leaves a room", func(t *testing.T) { testOtherUserLeave(t, deployment, "hs1", "hs1") })
t.Run("when remote user leaves a room", func(t *testing.T) { testOtherUserLeave(t, deployment, "hs1", "hs2") })
t.Run("when leaving a room with a local user", func(t *testing.T) { testLeave(t, deployment, "hs1", "hs1") })
t.Run("when leaving a room with a remote user", func(t *testing.T) {
runtime.SkipIf(t, runtime.Synapse) // https://github.com/matrix-org/synapse/issues/13650
testLeave(t, deployment, "hs1", "hs2")
})
t.Run("when local user rejoins a room", func(t *testing.T) { testOtherUserRejoin(t, deployment, "hs1", "hs1") })
t.Run("when remote user rejoins a room", func(t *testing.T) { testOtherUserRejoin(t, deployment, "hs1", "hs2") })
}

0 comments on commit 4acae02

Please sign in to comment.