diff --git a/changelog.d/19999.bugfix b/changelog.d/19999.bugfix new file mode 100644 index 0000000000..9439433274 --- /dev/null +++ b/changelog.d/19999.bugfix @@ -0,0 +1 @@ +Fix a bug where a user's dehydrated device (MSC3814) was deleted when their device list was synced from MAS (e.g. on logging out their last device), breaking offline key delivery. diff --git a/synapse/rest/synapse/mas/devices.py b/synapse/rest/synapse/mas/devices.py index 9d94a67675..b5d19d3cec 100644 --- a/synapse/rest/synapse/mas/devices.py +++ b/synapse/rest/synapse/mas/devices.py @@ -196,6 +196,16 @@ class MasSyncDevicesResource(MasBaseResource): current_devices_list = set(current_devices.keys()) target_device_list = set(body.devices) + # Exclude the dehydrated device (MSC3814): it has no MAS session, so MAS + # never lists it in the target set and the reconciliation below would + # otherwise treat it as extra and delete it. This mirrors the admin + # devices API and MAS's own legacy device-sync path, which both skip it. + dehydrated_device = await self.device_handler.get_dehydrated_device( + user_id=str(user_id) + ) + if dehydrated_device is not None: + current_devices_list.discard(dehydrated_device[0]) + to_add = target_device_list - current_devices_list to_delete = current_devices_list - target_device_list diff --git a/tests/rest/synapse/mas/test_devices.py b/tests/rest/synapse/mas/test_devices.py index 6b7596f1c6..e9035f4c0b 100644 --- a/tests/rest/synapse/mas/test_devices.py +++ b/tests/rest/synapse/mas/test_devices.py @@ -546,6 +546,48 @@ class MasSyncDevicesResource(BaseTestCase): devices = self.get_success(store.get_devices_by_user(str(self.alice_user_id))) self.assertEqual(set(devices.keys()), {"DEVICE1"}) + def test_sync_devices_preserves_dehydrated_device(self) -> None: + # Store a dehydrated device (MSC3814). It has no MAS session, so it is + # never part of the target device list, but it must survive a sync. + device_handler = self.hs.get_device_handler() + dehydrated_device_id = self.get_success( + device_handler.store_dehydrated_device( + user_id=str(self.alice_user_id), + device_id=None, + device_data={"device_data": {"foo": "bar"}}, + initial_device_display_name="dehydrated device", + keys_for_device={}, + ) + ) + + # Sync down to a single real device. The dehydrated device is not in the + # target list, but must not be deleted. + channel = self.make_request( + "POST", + "/_synapse/mas/sync_devices", + shorthand=False, + access_token=self.SHARED_SECRET, + content={ + "localpart": "alice", + "devices": ["DEVICE1"], + }, + ) + + self.assertEqual(channel.code, 200, channel.json_body) + self.assertEqual(channel.json_body, {}) + + # The real devices are reconciled, but the dehydrated device survives. + store = self.hs.get_datastores().main + devices = self.get_success(store.get_devices_by_user(str(self.alice_user_id))) + self.assertEqual(set(devices.keys()), {"DEVICE1", dehydrated_device_id}) + + # And it is still registered as the dehydrated device. + dehydrated = self.get_success( + device_handler.get_dehydrated_device(str(self.alice_user_id)) + ) + assert dehydrated is not None + self.assertEqual(dehydrated[0], dehydrated_device_id) + def test_sync_devices_add_and_delete(self) -> None: # Sync with a mix of additions and deletions channel = self.make_request(