Skip to main content

xmtp_mls/worker/device_sync/
archive.rs

1use super::DeviceSyncError;
2use crate::{context::XmtpSharedContext, groups::MlsGroup};
3use futures::{Stream, StreamExt};
4pub use xmtp_archive::*;
5use xmtp_db::{consent_record::StoredConsentRecord, group_message::StoredGroupMessage, prelude::*};
6use xmtp_proto::xmtp::device_sync::{BackupElement, backup_element::Element};
7
8use xmtp_events::{ArchiveRestored, ClientEvent, EventWriter};
9#[derive(Default)]
10struct ImportContext {
11    changed: bool,
12}
13
14pub async fn insert_importer(
15    importer: &mut ArchiveImporter,
16    context: &impl XmtpSharedContext,
17) -> Result<(), DeviceSyncError> {
18    insert_elements(importer, context).await
19}
20
21async fn insert_elements<S, E>(
22    elements: &mut S,
23    context: &impl XmtpSharedContext,
24) -> Result<(), DeviceSyncError>
25where
26    S: Stream<Item = Result<BackupElement, E>> + Unpin,
27    DeviceSyncError: From<E>,
28{
29    let mut import_ctx = ImportContext::default();
30    let result = async {
31        while let Some(element) = elements.next().await {
32            let element = element.map_err(DeviceSyncError::from)?;
33            // Propagate insert failures to the supervisor rather than skipping the record.
34            insert(element, context, &mut import_ctx)?;
35        }
36        Ok(())
37    }
38    .await;
39    if import_ctx.changed {
40        context.events().emit(
41            Some(ClientEvent::ArchiveRestored(ArchiveRestored {
42                complete: result.is_ok(),
43            })),
44            None,
45        );
46    }
47    result
48}
49
50fn insert(
51    element: BackupElement,
52    context: &impl XmtpSharedContext,
53    import_context: &mut ImportContext,
54) -> Result<(), DeviceSyncError> {
55    let Some(element) = element.element else {
56        return Ok(());
57    };
58
59    match element {
60        Element::Consent(consent) => {
61            let consent: StoredConsentRecord = consent.try_into()?;
62            // A stored element counts as a restore even when only its time moved.
63            import_context.changed |= context.db().insert_newer_consent_record(consent)?.applied;
64        }
65        Element::Group(save) => {
66            import_context.changed |= MlsGroup::restore_from_archive(context, &save)?;
67        }
68        Element::GroupMessage(message) => {
69            let message: StoredGroupMessage = message.try_into()?;
70            import_context.changed |= message.store_or_ignore_changed(&context.db())?;
71        }
72        _ => {}
73    }
74
75    Ok(())
76}
77
78#[cfg(test)]
79mod tests {
80    #![allow(unused)]
81    use super::*;
82    use crate::groups::send_message_opts::SendMessageOpts;
83    use crate::tester;
84    use crate::utils::{LocalTester, Tester};
85    use crate::worker::device_sync::{ArchiveOptions, BackupElementSelection};
86    use crate::{builder::ClientBuilder, utils::test::wait_for_min_intents};
87    use diesel::prelude::*;
88    use futures::AsyncReadExt;
89    use futures::io::{BufReader, Cursor};
90    use std::{path::Path, sync::Arc};
91    use xmtp_archive::exporter;
92    use xmtp_cryptography::utils::generate_local_wallet;
93    use xmtp_db::group_message::MsgQueryArgs;
94    use xmtp_db::{ConnectionExt, group::GroupMembershipState};
95    use xmtp_db::{
96        consent_record::StoredConsentRecord,
97        group::StoredGroup,
98        group_message::StoredGroupMessage,
99        schema::{consent_records, group_messages, groups},
100    };
101    use xmtp_mls_common::{
102        group::{DMMetadataOptions, GroupMetadataOptions},
103        group_mutable_metadata::MessageDisappearingSettings,
104    };
105    use xmtp_proto::types::GroupId;
106
107    /// Runs `on_first` once, when the export first writes past the archive
108    /// header, which happens while the snapshot's read transaction is open.
109    struct WriteDuringExport<F: FnMut()> {
110        archive: Vec<u8>,
111        on_first: Option<F>,
112    }
113
114    impl<F: FnMut()> std::io::Write for WriteDuringExport<F> {
115        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
116            if self.archive.len() >= 2 + xmtp_archive::NONCE_SIZE {
117                self.on_first.take().into_iter().for_each(|mut f| f());
118            }
119            self.archive.write(buf)
120        }
121
122        fn flush(&mut self) -> std::io::Result<()> {
123            Ok(())
124        }
125    }
126
127    /// A selected group whose mutable metadata cannot be decoded fails the
128    /// export, rather than exporting the group without that value. The
129    /// malformed admin list is committed locally through openmls, bypassing
130    /// the commit validation that keeps it out of real groups.
131    // verifies: ARCH-017
132    #[xmtp_common::test(unwrap_try = true)]
133    async fn archive_export_fails_on_undecodable_group_metadata() {
134        use openmls::{
135            component::ComponentData,
136            group::MlsGroup,
137            messages::proposals::{AppDataUpdateProposal, Proposal},
138        };
139        use openmls_traits::OpenMlsProvider;
140        use xmtp_archive::{ArchiveError, UnreadableGroup};
141        use xmtp_mls_common::app_data::component_id::ComponentId;
142
143        tester!(alix, disable_workers);
144        let conversation = alix.create_group(None, None)?;
145        let provider = alix.context.mls_provider();
146        let signer = &alix.context.identity().installation_keys;
147        let mut group = MlsGroup::load(
148            alix.context.mls_storage(),
149            &conversation.group_id.to_openmls(),
150        )??;
151        let malformed = vec![0xff; 3];
152        let mut stage = group
153            .commit_builder()
154            .add_proposal(Proposal::AppDataUpdate(Box::new(
155                AppDataUpdateProposal::update(ComponentId::ADMIN_LIST.as_u16(), malformed.clone()),
156            )))
157            .load_psks(provider.storage())?;
158        let mut updater = stage.app_data_dictionary_updater();
159        updater.set(ComponentData::from_parts(
160            ComponentId::ADMIN_LIST.as_u16(),
161            malformed.into(),
162        ));
163        stage.with_app_data_dictionary_updates(updater.changes());
164        stage
165            .build(provider.rand(), provider.crypto(), signer, |_| true)?
166            .stage_commit(&provider)?;
167        group.merge_pending_commit(&provider)?;
168
169        let opts = ArchiveOptions {
170            start_ns: None,
171            end_ns: None,
172            elements: vec![BackupElementSelection::Messages],
173            exclude_disappearing_messages: false,
174        };
175        let key = xmtp_common::rand_vec::<32>();
176        let failure = exporter::export(opts, alix.db(), &key, Vec::new());
177        assert!(
178            matches!(
179                failure,
180                Err(ArchiveError::UnreadableGroup {
181                    group_id,
182                    source: UnreadableGroup::MutableMetadata(_),
183                }) if group_id == conversation.group_id
184            ),
185            "export dropped an undecodable metadata value: {failure:?}"
186        );
187    }
188
189    /// With MESSAGES selected, a restore into a new installation holds every
190    /// eligible conversation whatever its creation time or messages: an empty
191    /// group, an empty DM, a group whose messages all precede the window, and
192    /// an old group with its in-window message. Consent edits and insertions
193    /// committed on another connection while the export is streaming are not
194    /// in the archive: the export is one snapshot. On wasm, whose single
195    /// connection the export holds, such writes are refused instead.
196    // verifies: ARCH-007, ARCH-017
197    // verifies: EVENT-004
198    #[xmtp_common::test(unwrap_try = true)]
199    async fn archive_snapshot_is_complete() {
200        use xmtp_db::consent_record::{ConsentState, ConsentType};
201        use xmtp_proto::xmtp::device_sync::{
202            backup_element::Element, consent_backup::ConsentStateSave,
203        };
204
205        tester!(alix, persistent_db, disable_workers);
206        tester!(bo, disable_workers);
207        let empty_group = alix.create_group(None, None)?;
208        let empty_dm = alix.find_or_create_dm(bo.inbox_id(), None).await?;
209        let outside = alix.create_group(None, None)?;
210        let outside_message = outside.send_message_optimistic(b"before", Default::default())?;
211        let old = alix.create_group(None, None)?;
212        let start_ns = xmtp_common::time::now_ns();
213        // The window's start is exclusive, and wasm clocks tick in whole
214        // milliseconds, so the in-window message must be sent a tick later.
215        xmtp_common::time::sleep(std::time::Duration::from_millis(2)).await;
216        // Large enough that the archive reaches the sink before consent is read.
217        let in_window = old.send_message_optimistic(&[7; 512 * 1024], Default::default())?;
218        let consent = |entity: &str, state| {
219            StoredConsentRecord::new(ConsentType::InboxId, state, entity.to_string())
220        };
221        alix.db()
222            .insert_or_replace_consent_records(&[consent("carol", ConsentState::Allowed)])?;
223
224        let opts = ArchiveOptions {
225            elements: vec![
226                BackupElementSelection::Messages,
227                BackupElementSelection::Consent,
228            ],
229            start_ns: Some(start_ns),
230            end_ns: None,
231            exclude_disappearing_messages: false,
232        };
233        let key = vec![7; 32];
234        let writer = alix.db();
235        let mut sink = WriteDuringExport {
236            archive: vec![],
237            on_first: Some(|| {
238                let written = writer.insert_or_replace_consent_records(&[
239                    consent("carol", ConsentState::Denied),
240                    consent("dave", ConsentState::Allowed),
241                ]);
242                // Native writers on other connections commit during the export;
243                // wasm's single connection is held by the export and refuses
244                // them, so neither can reach the snapshot.
245                #[cfg(not(target_arch = "wasm32"))]
246                written.unwrap();
247                #[cfg(target_arch = "wasm32")]
248                assert!(written.is_err(), "wasm wrote during the export");
249            }),
250        };
251        exporter::export(opts, alix.db(), &key, &mut sink)?;
252        assert!(
253            sink.on_first.is_none(),
254            "the export never wrote mid-snapshot"
255        );
256        let archive = sink.archive;
257
258        let reader = Box::pin(BufReader::new(Cursor::new(archive.clone())));
259        let consent: Vec<_> = ArchiveImporter::load(reader, &key)
260            .await?
261            .filter_map(|e| async move {
262                match e.ok()?.element? {
263                    Element::Consent(c) if c.entity == "carol" || c.entity == "dave" => Some(c),
264                    _ => None,
265                }
266            })
267            .collect()
268            .await;
269        assert!(
270            matches!(
271                consent.as_slice(),
272                [c] if c.entity == "carol" && c.state == ConsentStateSave::Allowed as i32
273            ),
274            "the export saw writes committed after its snapshot: {consent:?}"
275        );
276
277        tester!(alix2, from: alix, disable_workers);
278        let events = alix2.context.events().subscribe(
279            xmtp_events::EventFilter::new([
280                xmtp_events::EventKind::ArchiveRestored,
281                xmtp_events::EventKind::ConversationJoined,
282                xmtp_events::EventKind::MessageReceived,
283                xmtp_events::EventKind::ConsentChanged,
284            ]),
285            Some(64),
286        );
287        let reader = Box::pin(BufReader::new(Cursor::new(archive)));
288        let mut importer = ArchiveImporter::load(reader, &key).await?;
289        insert_importer(&mut importer, &alix2.context).await?;
290
291        let db = alix2.db();
292        for group in [&empty_group, &empty_dm, &outside, &old] {
293            assert_eq!(
294                db.find_group(&group.group_id)??.membership_state,
295                GroupMembershipState::Restored
296            );
297        }
298        assert!(db.get_group_message(&in_window)?.is_some());
299        assert!(db.get_group_message(&outside_message)?.is_none());
300        assert!(
301            matches!(events.drain().as_slice(), [xmtp_events::EventEnvelope { client: Some(ClientEvent::ArchiveRestored(restored)), .. }] if restored.complete)
302        );
303    }
304
305    // verifies: EVENT-001, EVENT-017
306    #[xmtp_common::test(unwrap_try = true)]
307    async fn partial_import_reports_incomplete_after_a_stored_change() {
308        tester!(alix, disable_workers);
309        let events = alix.context.events().subscribe(
310            xmtp_events::EventFilter::new([xmtp_events::EventKind::ArchiveRestored]),
311            Some(4),
312        );
313        let record = StoredConsentRecord::new(
314            xmtp_db::consent_record::ConsentType::InboxId,
315            xmtp_db::consent_record::ConsentState::Allowed,
316            "failed-import".into(),
317        );
318        let element = BackupElement {
319            element: Some(Element::Consent(record.into())),
320        };
321        let mut elements =
322            futures::stream::iter([Ok(element), Err(std::io::Error::other("archive cut"))]);
323        assert!(insert_elements(&mut elements, &alix.context).await.is_err());
324        assert!(
325            alix.db()
326                .get_consent_record(
327                    "failed-import".into(),
328                    xmtp_db::consent_record::ConsentType::InboxId,
329                )?
330                .is_some()
331        );
332        assert!(matches!(
333            events.drain().as_slice(),
334            [xmtp_events::EventEnvelope {
335                client: Some(ClientEvent::ArchiveRestored(restored)), ..
336            }] if !restored.complete
337        ));
338    }
339
340    // verifies: EVENT-004
341    #[xmtp_common::test(unwrap_try = true)]
342    async fn failed_import_suppresses_events_for_all_stored_elements() {
343        tester!(alix, disable_workers);
344        tester!(bo, disable_workers);
345        let group = alix.create_group(None, None)?;
346        let message_id = group.send_message_optimistic(b"archive message", Default::default())?;
347        alix.db()
348            .insert_or_replace_consent_records(&[StoredConsentRecord::new(
349                xmtp_db::consent_record::ConsentType::InboxId,
350                xmtp_db::consent_record::ConsentState::Denied,
351                bo.inbox_id().into(),
352            )])?;
353        let key = vec![9; 32];
354        let mut archive = vec![];
355        exporter::export(
356            ArchiveOptions {
357                elements: vec![
358                    BackupElementSelection::Messages,
359                    BackupElementSelection::Consent,
360                ],
361                start_ns: None,
362                end_ns: None,
363                exclude_disappearing_messages: false,
364            },
365            alix.db(),
366            &key,
367            &mut archive,
368        )?;
369        let importer =
370            ArchiveImporter::load(Box::pin(BufReader::new(Cursor::new(archive))), &key).await?;
371        let mut elements = importer
372            .map(|item| item.map_err(DeviceSyncError::from))
373            .chain(futures::stream::iter([Err(DeviceSyncError::from(
374                std::io::Error::other("archive cut"),
375            ))]));
376        tester!(restored, from: alix, disable_workers);
377        let events = restored.context.events().subscribe(
378            xmtp_events::EventFilter::new([
379                xmtp_events::EventKind::ArchiveRestored,
380                xmtp_events::EventKind::ConversationJoined,
381                xmtp_events::EventKind::MessageReceived,
382                xmtp_events::EventKind::ConsentChanged,
383            ]),
384            Some(64),
385        );
386        assert!(
387            insert_elements(&mut elements, &restored.context)
388                .await
389                .is_err()
390        );
391        assert!(restored.db().find_group(&group.group_id)?.is_some());
392        assert!(restored.db().get_group_message(&message_id)?.is_some());
393        assert!(
394            restored
395                .db()
396                .get_consent_record(
397                    bo.inbox_id().into(),
398                    xmtp_db::consent_record::ConsentType::InboxId
399                )?
400                .is_some()
401        );
402        assert!(
403            matches!(events.drain().as_slice(), [xmtp_events::EventEnvelope { client: Some(ClientEvent::ArchiveRestored(restored)), .. }] if !restored.complete)
404        );
405    }
406    // verifies: CONS-010
407    #[xmtp_common::test(unwrap_try = true)]
408    async fn archived_consent_keeps_the_latest_record() {
409        tester!(alix, disable_workers);
410        let records = [
411            (xmtp_db::consent_record::ConsentState::Allowed, 10),
412            (xmtp_db::consent_record::ConsentState::Allowed, 30),
413            (xmtp_db::consent_record::ConsentState::Denied, 20),
414        ];
415        let mut elements = futures::stream::iter(records.map(|(state, consented_at_ns)| {
416            let record = StoredConsentRecord {
417                entity_type: xmtp_db::consent_record::ConsentType::InboxId,
418                state,
419                entity: "archived-inbox".into(),
420                consented_at_ns,
421            };
422            Ok::<_, std::io::Error>(BackupElement {
423                element: Some(Element::Consent(record.into())),
424            })
425        }));
426        insert_elements(&mut elements, &alix.context).await?;
427        let stored = alix
428            .db()
429            .get_consent_record(
430                "archived-inbox".into(),
431                xmtp_db::consent_record::ConsentType::InboxId,
432            )?
433            .unwrap();
434        assert_eq!(stored.state, xmtp_db::consent_record::ConsentState::Allowed);
435        assert_eq!(stored.consented_at_ns, 30);
436    }
437
438    /// An import that only moves a stored record's time still stored an
439    /// element, so it reports a restore.
440    // verifies: EVENT-001
441    #[xmtp_common::test(unwrap_try = true)]
442    async fn archived_consent_time_move_reports_a_restore() {
443        tester!(alix, disable_workers);
444        let events = alix.context.events().subscribe(
445            xmtp_events::EventFilter::new([xmtp_events::EventKind::ArchiveRestored]),
446            Some(4),
447        );
448        let record = |consented_at_ns| StoredConsentRecord {
449            entity_type: xmtp_db::consent_record::ConsentType::InboxId,
450            state: xmtp_db::consent_record::ConsentState::Allowed,
451            entity: "time-move".into(),
452            consented_at_ns,
453        };
454        assert!(alix.db().insert_newer_consent_record(record(10))?.applied);
455        let mut elements = futures::stream::iter([Ok::<_, std::io::Error>(BackupElement {
456            element: Some(Element::Consent(record(30).into())),
457        })]);
458        insert_elements(&mut elements, &alix.context).await?;
459        let stored = alix
460            .db()
461            .get_consent_record(
462                "time-move".into(),
463                xmtp_db::consent_record::ConsentType::InboxId,
464            )?
465            .unwrap();
466        assert_eq!(stored.consented_at_ns, 30);
467        assert!(matches!(
468            events.drain().as_slice(),
469            [xmtp_events::EventEnvelope {
470                client: Some(ClientEvent::ArchiveRestored(restored)), ..
471            }] if restored.complete
472        ));
473    }
474
475    #[xmtp_common::test(unwrap_try = true)]
476    async fn archive_timestamp_keeps_a_message_received_during_import() {
477        tester!(alix, disable_workers);
478        let group = alix.create_group(None, None)?;
479        let save = xmtp_proto::xmtp::device_sync::group_backup::GroupSave {
480            id: group.group_id.to_vec(),
481            last_message_ns: Some(0),
482            ..Default::default()
483        };
484
485        group.send_message_optimistic(b"message during import", Default::default())?;
486        let current = alix.db().find_group(&group.group_id)??.last_message_ns;
487        assert!(current > Some(0));
488        MlsGroup::restore_from_archive(&alix.context, &save)?;
489        assert_eq!(
490            alix.db().find_group(&group.group_id)??.last_message_ns,
491            current
492        );
493    }
494
495    #[xmtp_common::test(unwrap_try = true)]
496    async fn test_archive_timestamps() {
497        tester!(alix, disable_workers);
498        tester!(alix2, from: alix);
499        tester!(bo, disable_workers);
500
501        let alix_group = alix
502            .create_group_with_members(&[bo.inbox_id()], None, None)
503            .await?;
504        alix_group.send_message(b"hi", Default::default()).await?;
505
506        alix2.sync_welcomes().await?;
507        bo.sync_welcomes().await?;
508
509        let alix2_group = alix2.group(&alix_group.group_id)?;
510        let bo_group = bo.group(&alix_group.group_id)?;
511
512        alix2_group.sync().await?;
513        bo_group.sync().await?;
514
515        // We want to send this message so that alix's group timestamp gets ahead of alix2.
516        alix_group
517            .send_message(b"Hello again", Default::default())
518            .await?;
519
520        let key = vec![7; 32];
521        let opts = ArchiveOptions {
522            start_ns: None,
523            end_ns: None,
524            elements: vec![
525                BackupElementSelection::Messages,
526                BackupElementSelection::Consent,
527            ],
528            exclude_disappearing_messages: false,
529        };
530        let export = {
531            let mut file = vec![];
532            exporter::export(opts, alix.db(), &key, &mut file)?;
533            file
534        };
535
536        tester!(alix3, from: alix);
537
538        // Now we will have alix2 and alix3 import the archives.
539        // One installation has the group already, one does not.
540        let reader = Box::pin(BufReader::new(Cursor::new(export.clone())));
541        let mut importer = ArchiveImporter::load(reader, &key).await?;
542        insert_importer(&mut importer, &alix2.context).await?;
543
544        let reader = Box::pin(BufReader::new(Cursor::new(export)));
545        let mut importer = ArchiveImporter::load(reader, &key).await?;
546        insert_importer(&mut importer, &alix3.context).await?;
547
548        let alix_timestamp = alix
549            .db()
550            .find_group(&alix_group.group_id)??
551            .last_message_ns?;
552        let alix2_timestamp = alix2
553            .db()
554            .find_group(&alix_group.group_id)??
555            .last_message_ns?;
556        let alix3_timestamp = alix3
557            .db()
558            .find_group(&alix_group.group_id)??
559            .last_message_ns?;
560
561        // Alix2's older timestamp on the existing group should be updated.
562        assert_eq!(alix2_timestamp, alix_timestamp);
563        // Alix3's timestamp should equal alix's timestamp.
564        assert_eq!(alix3_timestamp, alix_timestamp);
565    }
566
567    #[xmtp_common::test(unwrap_try = true)]
568    async fn test_dm_archive() {
569        tester!(alix, disable_workers);
570        tester!(bo, disable_workers);
571
572        let alix_bo_dm = alix.find_or_create_dm(bo.inbox_id(), None).await?;
573        let archived_message_id = alix_bo_dm
574            .send_message(b"old group", Default::default())
575            .await?;
576
577        let timestamp = alix
578            .db()
579            .find_group(&alix_bo_dm.group_id)??
580            .last_message_ns?;
581
582        let key = vec![7; 32];
583        let opts = ArchiveOptions {
584            start_ns: None,
585            end_ns: None,
586            elements: vec![
587                BackupElementSelection::Messages,
588                BackupElementSelection::Consent,
589            ],
590            exclude_disappearing_messages: false,
591        };
592        let export = {
593            let mut file = vec![];
594
595            exporter::export(opts, alix.db(), &key, &mut file)?;
596            file
597        };
598
599        tester!(alix2, from: alix);
600        let reader = Box::pin(BufReader::new(Cursor::new(export)));
601        let mut importer = ArchiveImporter::load(reader, &key).await?;
602        insert_importer(&mut importer, &alix2.context).await?;
603
604        // Imported history has no joined MLS state for this installation.
605        // Receipt may retain the old prefix, but processing must wait for a Welcome.
606        let restored = alix2.group(&alix_bo_dm.group_id)?;
607        let restored_topic = xmtp_db::incoming_envelope::StreamTopic::group(alix_bo_dm.group_id);
608        let before_join = alix2.db().topic_progress(&restored_topic)?;
609        crate::mls_store::MlsStore::new(alix2.context.clone())
610            .receive_topics_once(
611                &[xmtp_proto::types::Topic::new_group_message(
612                    alix_bo_dm.group_id,
613                )],
614                alix2
615                    .context
616                    .incoming_runtime()
617                    .policy()
618                    .incoming_limits(xmtp_db::incoming_envelope::NetworkEntityKind::Group),
619            )
620            .await?;
621        let received_before_join = alix2.db().topic_progress(&restored_topic)?;
622        assert!(received_before_join.received > before_join.processed);
623        assert!(matches!(
624            restored.process_pending_group_head(None)?,
625            crate::groups::mls_sync::GroupHeadOutcome::Inactive
626        ));
627        assert_eq!(
628            alix2.db().topic_progress(&restored_topic)?.processed,
629            before_join.processed
630        );
631        assert!(
632            !alix2
633                .db()
634                .pending_states_through(&restored_topic, received_before_join.received)?
635                .is_empty()
636        );
637
638        let alix2_bo_dm = alix2.find_or_create_dm(bo.inbox_id(), None).await?;
639        assert_ne!(alix_bo_dm.group_id, alix2_bo_dm.group_id);
640        let mut msgs = alix2_bo_dm.find_messages(&MsgQueryArgs::default())?;
641        assert_eq!(msgs.len(), 2);
642        assert!(
643            msgs.iter()
644                .any(|m| m.decrypted_message_bytes == b"old group")
645        );
646
647        // assert_eq!(alix2_bo_dm.test_last_message_bytes().await??, b"old group");
648
649        let timestamp2 = alix2
650            .db()
651            .find_group(&alix_bo_dm.group_id)??
652            .last_message_ns?;
653        assert_eq!(timestamp, timestamp2);
654
655        let live_message_id = alix2_bo_dm
656            .send_message(b"hi bo", Default::default())
657            .await?;
658
659        bo.sync_all_welcomes_and_groups(None).await?;
660        let bo_alix2_dm = bo.group(&alix2_bo_dm.group_id)?;
661        assert_eq!(bo_alix2_dm.test_last_message_bytes().await??, b"hi bo");
662
663        // Ordinary sync must add the new installation to the original DM too.
664        alix2.sync_all_welcomes_and_groups(None).await?;
665        let rejoined_original = alix2.group(&alix_bo_dm.group_id)?;
666        assert!(rejoined_original.is_active()?);
667        let stitched = alix2_bo_dm.find_messages(&MsgQueryArgs::default())?;
668        assert_eq!(stitched.len(), 4);
669        let application_ids: Vec<_> = stitched
670            .iter()
671            .filter(|message| message.kind == xmtp_db::group_message::GroupMessageKind::Application)
672            .map(|message| message.id.clone())
673            .collect();
674        assert_eq!(application_ids, vec![archived_message_id, live_message_id]);
675        assert_eq!(alix2.find_groups(Default::default())?.len(), 1);
676        let bo_original = bo.group(&alix_bo_dm.group_id)?;
677        rejoined_original.test_can_talk_with(&bo_original).await?;
678        bo_original.test_can_talk_with(&rejoined_original).await?;
679    }
680
681    // verifies: ARCH-015, ARCH-020
682    #[xmtp_common::test(unwrap_try = true)]
683    async fn foreign_archive_preserves_dm_pair_and_history() {
684        use crate::groups::GroupError;
685        use xmtp_db::{
686            consent_record::{ConsentState, ConsentType},
687            group::{DmIdExt, QueryGroup},
688            readd_status::ReaddStatus,
689        };
690        use xmtp_proto::api::HasStats;
691
692        tester!(alix, disable_workers);
693        tester!(bo, disable_workers);
694        tester!(charlie, disable_workers);
695        tester!(dana, disable_workers);
696        let source = alix.find_or_create_dm(bo.inbox_id(), None).await?;
697        let alix_id = source
698            .send_message(b"from alix", Default::default())
699            .await?;
700        bo.sync_welcomes().await?;
701        let bo_group = bo.group(&source.group_id)?;
702        let bo_id = bo_group
703            .send_message(b"from bo", Default::default())
704            .await?;
705        source.sync().await?;
706        let archived_dm_id = alix
707            .db()
708            .find_group(&source.group_id)?
709            .expect("source DM")
710            .dm_id
711            .expect("DM pair");
712        assert!(!archived_dm_id.contains(charlie.inbox_id()));
713
714        let key = vec![0x38; 32];
715        let opts = ArchiveOptions {
716            start_ns: None,
717            end_ns: None,
718            elements: vec![BackupElementSelection::Messages],
719            exclude_disappearing_messages: false,
720        };
721        let mut export = vec![];
722        exporter::export(opts.clone(), alix.db(), &key, &mut export)?;
723        let reader = Box::pin(BufReader::new(Cursor::new(export)));
724        let mut importer = ArchiveImporter::load(reader, &key).await?;
725        insert_importer(&mut importer, &charlie.context).await?;
726
727        let stored = charlie
728            .db()
729            .find_group(&source.group_id)?
730            .expect("Restored DM");
731        assert_eq!(stored.membership_state, GroupMembershipState::Restored);
732        assert_eq!(stored.dm_id.as_deref(), Some(archived_dm_id.as_str()));
733        assert_eq!(archived_dm_id.other_inbox_id(charlie.inbox_id()), None);
734        let restored = charlie.group(&source.group_id)?;
735        assert_eq!(
736            restored
737                .metadata()
738                .await?
739                .dm_members
740                .expect("stub pair")
741                .to_string(),
742            archived_dm_id
743        );
744        assert!(!restored.is_active()?);
745        let stats = charlie.context.api().api_client.mls_stats();
746        stats.clear();
747        assert!(matches!(
748            restored.send_message(b"inactive", Default::default()).await,
749            Err(GroupError::GroupInactive)
750        ));
751        assert!(matches!(
752            restored.sync().await,
753            Err(GroupError::GroupInactive)
754        ));
755        let Err(sync_summary) = restored.sync_with_conn().await else {
756            panic!("Restored sync_with_conn must reject the request");
757        };
758        assert!(matches!(
759            sync_summary.other.as_deref(),
760            Some(GroupError::GroupInactive)
761        ));
762        assert!(matches!(
763            restored.receive().await,
764            Err(GroupError::GroupInactive)
765        ));
766        assert!(matches!(
767            charlie.client.sync_all_groups(vec![restored.clone()]).await,
768            Err(GroupError::GroupInactive)
769        ));
770        assert_eq!(stats.publish.get_count(), 0);
771        let joined = charlie.create_group(None, None)?;
772        joined.add_members(&[dana.inbox_id()]).await?;
773        dana.sync_welcomes().await?;
774        let dana_group = dana.group(&joined.group_id)?;
775        let incoming_id = dana_group
776            .send_message(b"joined group", Default::default())
777            .await?;
778        let Err(GroupError::Sync(summary)) = charlie
779            .client
780            .sync_all_groups(vec![restored.clone(), joined])
781            .await
782        else {
783            panic!("mixed explicit sync must report the inactive group");
784        };
785        assert!(matches!(
786            summary.other.as_deref(),
787            Some(GroupError::GroupInactive)
788        ));
789        assert!(charlie.db().get_group_message(&incoming_id)?.is_some());
790        let sweep = charlie.client.sync_all_welcomes_and_groups(None).await?;
791        assert_eq!(sweep.num_eligible, 1);
792        assert_eq!(sweep.num_synced, 1);
793        assert!(!restored.is_active()?);
794        let db = charlie.db();
795        // Even an allowed archived DM has no authority for remote recovery.
796        db.insert_or_replace_consent_records(&[StoredConsentRecord::new(
797            ConsentType::ConversationId,
798            ConsentState::Allowed,
799            hex::encode(source.group_id),
800        )])?;
801        let excludes_publish = db
802            .get_conversation_ids_for_remote_log_publish()?
803            .iter()
804            .all(|group| group.id != source.group_id);
805        let excludes_download = db
806            .get_conversation_ids_for_remote_log_download()?
807            .iter()
808            .all(|group| group.id != source.group_id);
809        let excludes_fork_check = db
810            .get_conversation_ids_for_fork_check()?
811            .iter()
812            .all(|id| id.as_slice() != source.group_id.as_ref());
813        db.set_group_commit_log_forked_status(&source.group_id, Some(true))?;
814        let excludes_requesting_readds = db
815            .get_conversation_ids_for_requesting_readds()?
816            .iter()
817            .all(|group| group.group_id != source.group_id);
818        ReaddStatus {
819            group_id: source.group_id,
820            installation_id: vec![0x42; 32],
821            requested_at_sequence_id: Some(1),
822            responded_at_sequence_id: None,
823        }
824        .store(&db)?;
825        let excludes_responding_readds = db
826            .get_conversation_ids_for_responding_readds()?
827            .iter()
828            .all(|group| group.group_id != source.group_id);
829        assert_eq!(
830            [
831                excludes_publish,
832                excludes_download,
833                excludes_fork_check,
834                excludes_requesting_readds,
835                excludes_responding_readds,
836            ],
837            [true; 5],
838            "Restored DM must be absent from every recovery selector"
839        );
840        for (id, sender, bytes) in [
841            (&alix_id, alix.inbox_id(), b"from alix".as_slice()),
842            (&bo_id, bo.inbox_id(), b"from bo".as_slice()),
843        ] {
844            let message = charlie.db().get_group_message(id)?.expect("pair history");
845            assert_eq!(message.sender_inbox_id, sender);
846            assert_eq!(message.decrypted_message_bytes, bytes);
847        }
848
849        let mut reexport = vec![];
850        exporter::export(opts, charlie.db(), &key, &mut reexport)?;
851        let reader = Box::pin(BufReader::new(Cursor::new(reexport)));
852        let mut exported = ArchiveImporter::load(reader, &key).await?;
853        while let Some(element) = exported.next().await {
854            match element?.element {
855                Some(Element::Group(group)) => assert_ne!(group.id, source.group_id.as_ref()),
856                Some(Element::GroupMessage(message)) => {
857                    assert_ne!(message.group_id, source.group_id.as_ref());
858                }
859                _ => {}
860            }
861        }
862
863        let reopened = crate::builder::ClientBuilder::from_client(charlie.client.clone())
864            .with_disable_workers(true)
865            .build()
866            .await?;
867        let reopened_group = reopened.group(&source.group_id)?;
868        assert_eq!(
869            reopened_group.dm_id.as_deref(),
870            Some(archived_dm_id.as_str())
871        );
872        assert!(!reopened_group.is_active()?);
873        assert!(reopened.db().get_group_message(&alix_id)?.is_some());
874        assert!(reopened.db().get_group_message(&bo_id)?.is_some());
875    }
876
877    // verifies: ARCH-013
878    #[xmtp_common::test(unwrap_try = true)]
879    async fn archive_import_leaves_a_known_message_unchanged() {
880        tester!(alix, disable_workers);
881        let group = alix.create_group(None, None)?;
882        let known_id = group.send_message(b"known", Default::default()).await?;
883        let known = alix
884            .db()
885            .get_group_message(&known_id)?
886            .expect("known message");
887        let mut save: xmtp_proto::xmtp::device_sync::message_backup::GroupMessageSave =
888            known.clone().into();
889        save.sender_inbox_id = hex::encode([0x43; 32]);
890        save.decrypted_message_bytes = b"ignored replacement".to_vec();
891        let mut duplicate = futures::stream::iter([Ok::<_, std::io::Error>(BackupElement {
892            element: Some(Element::GroupMessage(save)),
893        })]);
894        insert_elements(&mut duplicate, &alix.context).await?;
895        assert_eq!(alix.db().get_group_message(&known_id)?, Some(known));
896    }
897
898    /// A DM restored before a failing element is a completed element. Its
899    /// archived activity must survive the failed import, even when no
900    /// retained message carries that timestamp.
901    // verifies: ARCH-021
902    #[xmtp_common::test(unwrap_try = true)]
903    async fn failed_import_keeps_archived_activity_of_restored_prefix() {
904        use xmtp_mls_common::group_metadata::DmMembers;
905        use xmtp_proto::xmtp::device_sync::{
906            group_backup::GroupSave,
907            message_backup::{GroupMessageKindSave, GroupMessageSave},
908        };
909
910        tester!(caro, disable_workers);
911        let pair = DmMembers {
912            member_one_inbox_id: hex::encode([0x41; 32]),
913            member_two_inbox_id: hex::encode([0x42; 32]),
914        };
915        let group_id = vec![0x65; 16];
916        let archived_activity = 1_234_567;
917        let group = GroupSave {
918            id: group_id.clone(),
919            conversation_type: 2,
920            dm_id: Some(pair.to_string()),
921            last_message_ns: Some(archived_activity),
922            ..Default::default()
923        };
924        // A recognized message element with an unspecified kind is malformed.
925        let malformed = GroupMessageSave {
926            id: vec![0x66; 32],
927            group_id: group_id.clone(),
928            kind: GroupMessageKindSave::Unspecified as i32,
929            sender_inbox_id: pair.member_one_inbox_id.clone(),
930            ..Default::default()
931        };
932
933        let events = caro.context.events().subscribe(
934            xmtp_events::EventFilter::new([xmtp_events::EventKind::ArchiveRestored]),
935            Some(4),
936        );
937        let mut elements = futures::stream::iter([
938            Ok::<_, std::io::Error>(BackupElement {
939                element: Some(Element::Group(group)),
940            }),
941            Ok(BackupElement {
942                element: Some(Element::GroupMessage(malformed.clone())),
943            }),
944        ]);
945        let error = insert_elements(&mut elements, &caro.context)
946            .await
947            .expect_err("a malformed element fails the import");
948        assert!(
949            matches!(
950                error,
951                DeviceSyncError::ProtoConversion(xmtp_proto::ConversionError::Unspecified(
952                    "message_kind"
953                ))
954            ),
955            "unexpected import error: {error:?}"
956        );
957
958        let restored = caro
959            .db()
960            .find_group(&GroupId::try_from(group_id.as_slice())?)??;
961        assert_eq!(restored.membership_state, GroupMembershipState::Restored);
962        assert_eq!(restored.last_message_ns, Some(archived_activity));
963        assert!(caro.db().get_group_message(&malformed.id)?.is_none());
964        assert!(matches!(
965            events.drain().as_slice(),
966            [xmtp_events::EventEnvelope {
967                client: Some(ClientEvent::ArchiveRestored(restored)), ..
968            }] if !restored.complete
969        ));
970    }
971
972    // verifies: DMS-001, ARCH-021
973    #[xmtp_common::test(unwrap_try = true)]
974    async fn malformed_archived_dm_id_fails_before_group_insert() {
975        tester!(alix, disable_workers);
976        let group_id = vec![0x64; 16];
977        let save = xmtp_proto::xmtp::device_sync::group_backup::GroupSave {
978            id: group_id.clone(),
979            conversation_type: 2,
980            dm_id: Some("dm:malformed".into()),
981            ..Default::default()
982        };
983        let mut elements = futures::stream::iter([Ok::<_, std::io::Error>(BackupElement {
984            element: Some(Element::Group(save)),
985        })]);
986        assert!(insert_elements(&mut elements, &alix.context).await.is_err());
987        assert!(
988            alix.db()
989                .find_group(&GroupId::try_from(group_id.as_slice())?)?
990                .is_none()
991        );
992    }
993
994    // verifies: EVENT-001, EVENT-017
995    #[rstest::rstest]
996    #[xmtp_common::test]
997    async fn test_buffer_export_import() {
998        use futures::io::BufReader;
999        use futures_util::AsyncReadExt;
1000
1001        tester!(alix);
1002        tester!(bo);
1003
1004        let alix_group = alix.create_group(None, None).unwrap();
1005        alix_group.add_members(&[bo.inbox_id()]).await.unwrap();
1006        alix_group
1007            .send_message(b"hello there", SendMessageOpts::default())
1008            .await
1009            .unwrap();
1010
1011        let opts = ArchiveOptions {
1012            start_ns: None,
1013            end_ns: None,
1014            elements: vec![
1015                BackupElementSelection::Messages,
1016                BackupElementSelection::Consent,
1017            ],
1018            exclude_disappearing_messages: false,
1019        };
1020
1021        let key = vec![7; 32];
1022
1023        let file = {
1024            let mut file = Vec::new();
1025            exporter::export(opts, alix.db(), &key, &mut file).unwrap();
1026            file
1027        };
1028
1029        let alix2_wallet = generate_local_wallet();
1030        let alix2 = ClientBuilder::new_test_client(&alix2_wallet).await;
1031        let events = alix2.context.events().subscribe(
1032            xmtp_events::EventFilter::new([xmtp_events::EventKind::ArchiveRestored]),
1033            Some(4),
1034        );
1035
1036        // No messages
1037        let messages: Vec<StoredGroupMessage> = alix2
1038            .context
1039            .db()
1040            .raw_query(|conn| {
1041                group_messages::table
1042                    .select(StoredGroupMessage::as_select())
1043                    .load(conn)
1044            })
1045            .unwrap();
1046        assert_eq!(messages.len(), 0);
1047
1048        let reader = BufReader::new(Cursor::new(file.clone()));
1049        let reader = Box::pin(reader);
1050        let mut importer = ArchiveImporter::load(reader, &key).await.unwrap();
1051        insert_importer(&mut importer, &alix2.context)
1052            .await
1053            .unwrap();
1054        assert!(matches!(
1055            events.drain().as_slice(),
1056            [xmtp_events::EventEnvelope {
1057                client: Some(xmtp_events::ClientEvent::ArchiveRestored(restored)), ..
1058            }] if restored.complete
1059        ));
1060
1061        let reader = Box::pin(BufReader::new(Cursor::new(file)));
1062        let mut second = ArchiveImporter::load(reader, &key).await.unwrap();
1063        insert_importer(&mut second, &alix2.context).await.unwrap();
1064        assert!(events.drain().is_empty());
1065
1066        // One message.
1067        let messages: Vec<StoredGroupMessage> = alix2
1068            .context
1069            .db()
1070            .raw_query(|conn| {
1071                group_messages::table
1072                    .select(StoredGroupMessage::as_select())
1073                    .load(conn)
1074            })
1075            .unwrap();
1076        assert_eq!(messages.len(), 1);
1077    }
1078
1079    // verifies: JOIN-080
1080    #[xmtp_common::test(unwrap_try = true)]
1081    #[cfg(not(target_arch = "wasm32"))]
1082    async fn test_file_backup() {
1083        use crate::{groups::send_message_opts::SendMessageOpts, tester};
1084        use diesel::QueryDsl;
1085        use xmtp_db::group::{ConversationType, GroupQueryArgs};
1086
1087        tester!(alix, sync_worker, triggers);
1088        tester!(bo);
1089
1090        let alix_group = alix.create_group(None, None)?;
1091
1092        // wait for user preference update
1093        wait_for_min_intents(&alix.context.db(), 2).await?;
1094
1095        alix_group.add_members(&[bo.inbox_id()]).await?;
1096        alix_group.update_group_name("My group".to_string()).await?;
1097
1098        bo.sync_welcomes().await?;
1099        let bo_group = bo.group(&alix_group.group_id)?;
1100
1101        // wait for add member intent/commit
1102        wait_for_min_intents(&alix.context.db(), 1).await?;
1103
1104        alix_group
1105            .send_message(b"hello there", SendMessageOpts::default())
1106            .await?;
1107
1108        // wait for send message intent/commit publish
1109        // Wait for Consent state update
1110        wait_for_min_intents(&alix.context.db(), 4).await?;
1111
1112        let mut consent_records: Vec<StoredConsentRecord> = alix
1113            .context
1114            .db()
1115            .raw_query(|conn| consent_records::table.load(conn))?;
1116        assert_eq!(consent_records.len(), 1);
1117        let old_consent_record = consent_records.pop()?;
1118
1119        let mut groups: Vec<StoredGroup> = alix
1120            .context
1121            .db()
1122            .raw_query(|conn| groups::table.load(conn))?;
1123        assert_eq!(groups.len(), 2);
1124        let old_group = groups.pop()?;
1125
1126        let old_messages: Vec<StoredGroupMessage> = alix.context.db().raw_query(|conn| {
1127            group_messages::table
1128                .select(StoredGroupMessage::as_select())
1129                .load(conn)
1130        })?;
1131        assert_eq!(old_messages.len(), 4);
1132
1133        let opts = ArchiveOptions {
1134            start_ns: None,
1135            end_ns: None,
1136            elements: vec![
1137                BackupElementSelection::Messages,
1138                BackupElementSelection::Consent,
1139            ],
1140            exclude_disappearing_messages: false,
1141        };
1142
1143        let key = xmtp_common::rand_vec::<32>();
1144        let path = Path::new("archive.xmtp");
1145        let _ = tokio::fs::remove_file(path).await;
1146        exporter::ArchiveExporter::export_to_file(opts, alix.db(), path, &key).await?;
1147
1148        tester!(alix2, sync_worker);
1149        alix2.device_sync_client().wait_for_sync_worker_init().await;
1150
1151        // No consent before
1152        let consent_records: Vec<StoredConsentRecord> = alix2
1153            .context
1154            .db()
1155            .raw_query(|conn| consent_records::table.load(conn))?;
1156        assert_eq!(consent_records.len(), 0);
1157
1158        let mut importer = ArchiveImporter::from_file(path, &key).await?;
1159        insert_importer(&mut importer, &alix2.context)
1160            .await
1161            .unwrap();
1162
1163        // Consent is there after the import
1164        let consent_records: Vec<StoredConsentRecord> = alix2
1165            .context
1166            .db()
1167            .raw_query(|conn| consent_records::table.load(conn))?;
1168        assert_eq!(consent_records.len(), 1);
1169        // It's the same consent record.
1170        assert_eq!(consent_records[0], old_consent_record);
1171
1172        let groups: Vec<StoredGroup> = alix2.context.db().raw_query(|conn| {
1173            groups::table
1174                .filter(groups::conversation_type.ne_all(ConversationType::virtual_types()))
1175                .load(conn)
1176        })?;
1177        assert_eq!(groups.len(), 1);
1178        // It's the same group
1179        assert_eq!(groups[0].id, old_group.id);
1180
1181        let messages: Vec<StoredGroupMessage> = alix2.context.db().raw_query(|conn| {
1182            group_messages::table
1183                .select(StoredGroupMessage::as_select())
1184                .filter(group_messages::group_id.eq(&groups[0].id))
1185                .load(conn)
1186        })?;
1187        // Only the application messages should sync
1188        assert_eq!(messages.len(), 1);
1189        for msg in messages {
1190            let old_msg = old_messages.iter().find(|m| msg.id == m.id)?;
1191            assert_eq!(old_msg.authority_id, msg.authority_id);
1192            assert_eq!(old_msg.decrypted_message_bytes, msg.decrypted_message_bytes);
1193            assert_eq!(old_msg.sent_at_ns, msg.sent_at_ns);
1194            assert_eq!(old_msg.sender_installation_id, msg.sender_installation_id);
1195            assert_eq!(old_msg.sender_inbox_id, msg.sender_inbox_id);
1196            assert_eq!(old_msg.group_id, msg.group_id);
1197        }
1198
1199        let alix2_group = alix2.group(&old_group.id)?;
1200        // Loading all the groups works fine
1201        let _groups = alix2.find_groups(GroupQueryArgs::default())?;
1202        // Can fetch the group name no problem
1203        alix2_group.group_name()?;
1204        assert!(!alix2_group.is_active()?);
1205
1206        // Add the new inbox to the groups
1207        alix.group(&old_group.id)?
1208            .add_members(&[alix2.inbox_id()])
1209            .await?;
1210        alix2.sync_welcomes().await?;
1211
1212        // The group restores to being fully functional
1213        let alix2_group = alix2.group(&old_group.id)?;
1214        assert!(alix2_group.is_active()?);
1215
1216        // The old messages should be stitched in
1217        let msgs = alix2_group.find_messages(&MsgQueryArgs::default())?;
1218        let old_msg_exists = msgs
1219            .iter()
1220            .any(|msg| msg.decrypted_message_bytes == b"hello there");
1221        assert!(old_msg_exists);
1222
1223        // Bo should see the new message from alix2
1224        alix2_group
1225            .send_message(b"this should send", SendMessageOpts::default())
1226            .await?;
1227        bo_group.sync().await?;
1228        let msgs = bo_group.find_messages(&MsgQueryArgs::default())?;
1229        let new_msg_exists = msgs
1230            .iter()
1231            .any(|msg| msg.decrypted_message_bytes == b"this should send");
1232        assert!(new_msg_exists);
1233
1234        // cleanup
1235        let _ = tokio::fs::remove_file(path).await;
1236    }
1237
1238    #[xmtp_common::test(unwrap_try = true)]
1239    #[cfg(not(target_arch = "wasm32"))]
1240    async fn test_legacy_archive_import() {
1241        use std::path::PathBuf;
1242
1243        use crate::tester;
1244
1245        let key = vec![0; 32];
1246        let path = PathBuf::from("tests/assets/archive-legacy.xmtp");
1247        let mut importer = ArchiveImporter::from_file(path, &key).await?;
1248
1249        tester!(alix);
1250
1251        let result = insert_importer(&mut importer, &alix.context).await;
1252        assert!(result.is_ok());
1253    }
1254
1255    /// This archive was generated by the legacy creation build before groups
1256    /// were born with an AppData dictionary. Import must preserve the saved
1257    /// metadata while it creates dictionary-native backup stubs.
1258    // verifies: ARCH-022
1259    #[xmtp_common::test(unwrap_try = true)]
1260    #[cfg(not(target_arch = "wasm32"))]
1261    async fn test_import_current_legacy_archive_metadata() {
1262        use std::path::PathBuf;
1263
1264        use crate::groups::group_permissions::PolicySet;
1265        use xmtp_db::group::ConversationType;
1266
1267        let key = vec![9; 32];
1268        let path = PathBuf::from("tests/assets/archive-current-legacy-metadata.xmtp");
1269        let mut preview = ArchiveImporter::from_file(path.clone(), &key).await?;
1270        let mut archived_dm_id = None;
1271        while let Some(element) = preview.next().await {
1272            if let Some(Element::Group(group)) = element?.element {
1273                archived_dm_id = archived_dm_id.or(group.dm_id);
1274            }
1275        }
1276        let archived_dm_id = archived_dm_id.expect("legacy DM pair");
1277        let mut importer = ArchiveImporter::from_file(path, &key).await?;
1278        tester!(alix, disable_workers);
1279        let pair = crate::groups::parse_canonical_dm_id(Some(&archived_dm_id))?;
1280        assert_ne!(pair.member_one_inbox_id, alix.inbox_id());
1281        assert_ne!(pair.member_two_inbox_id, alix.inbox_id());
1282        insert_importer(&mut importer, &alix.context).await?;
1283
1284        let groups: Vec<StoredGroup> = alix
1285            .context
1286            .db()
1287            .raw_query(|conn| groups::table.load(conn))?;
1288        assert_eq!(groups.len(), 2);
1289        for record in &groups {
1290            let group = alix.group(&record.id)?;
1291            group.with_group_snapshot(|mls| {
1292                assert!(mls.extensions().app_data_dictionary().is_some());
1293                Ok(())
1294            })?;
1295        }
1296
1297        let restored_group_record = groups
1298            .iter()
1299            .find(|group| group.conversation_type == ConversationType::Group)
1300            .expect("legacy group archive entry");
1301        assert_eq!(restored_group_record.message_disappear_from_ns, Some(123));
1302        assert_eq!(restored_group_record.message_disappear_in_ns, Some(456));
1303        let restored_group = alix.group(&restored_group_record.id)?;
1304        assert_eq!(restored_group.group_name()?, "legacy archive group name");
1305        assert_eq!(
1306            restored_group.group_description()?,
1307            "legacy archive group description"
1308        );
1309        assert_eq!(
1310            restored_group.group_image_url_square()?,
1311            "https://example.com/legacy-archive.png"
1312        );
1313        assert_eq!(restored_group.app_data()?, "legacy archive app data");
1314        assert_eq!(restored_group.permissions()?.policies, PolicySet::default());
1315
1316        let restored_dm_record = groups
1317            .iter()
1318            .find(|group| group.conversation_type == ConversationType::Dm)
1319            .expect("legacy DM archive entry");
1320        assert_eq!(
1321            restored_dm_record.dm_id.as_deref(),
1322            Some(archived_dm_id.as_str())
1323        );
1324        assert_eq!(restored_dm_record.message_disappear_from_ns, Some(123));
1325        assert_eq!(restored_dm_record.message_disappear_in_ns, Some(456));
1326        let restored_dm = alix.group(&restored_dm_record.id)?;
1327        assert_eq!(restored_dm.group_name()?, "legacy archive dm name");
1328        assert_eq!(
1329            restored_dm.group_description()?,
1330            "legacy archive dm description"
1331        );
1332        assert_eq!(
1333            restored_dm.group_image_url_square()?,
1334            "https://example.com/legacy-dm-archive.png"
1335        );
1336        assert_eq!(restored_dm.app_data()?, "legacy archive dm app data");
1337    }
1338
1339    /// Dictionary metadata must survive archive export and import.
1340    #[xmtp_common::test(unwrap_try = true)]
1341    async fn test_archive_includes_dictionary_groups() {
1342        tester!(alix, disable_workers);
1343        tester!(bo, disable_workers);
1344
1345        let alix_group = alix
1346            .create_group_with_members(&[bo.inbox_id()], None, None)
1347            .await?;
1348        alix_group.send_message(b"hi", Default::default()).await?;
1349
1350        // Update metadata on the dictionary-native group so the exported
1351        // values come from the AppData dictionary.
1352
1353        alix_group
1354            .update_group_name("post-migration name".to_string())
1355            .await?;
1356        alix_group
1357            .update_group_description("post-migration description".to_string())
1358            .await?;
1359        alix_group
1360            .update_group_image_url_square("https://example.com/post-migration.png".to_string())
1361            .await?;
1362
1363        // A second group must restore alongside the first group.
1364        let second_group = alix
1365            .create_group_with_members(&[bo.inbox_id()], None, None)
1366            .await?;
1367        second_group
1368            .update_group_name("second group name".to_string())
1369            .await?;
1370
1371        let key = vec![7; 32];
1372        let opts = ArchiveOptions {
1373            start_ns: None,
1374            end_ns: None,
1375            elements: vec![
1376                BackupElementSelection::Messages,
1377                BackupElementSelection::Consent,
1378            ],
1379            exclude_disappearing_messages: false,
1380        };
1381        let export = {
1382            let mut file = vec![];
1383            exporter::export(opts, alix.db(), &key, &mut file)?;
1384            file
1385        };
1386
1387        // Fresh installation of the same inbox restores from the
1388        // archive only (workers disabled, no welcome sync).
1389        tester!(alix2, from: alix);
1390        let reader = Box::pin(BufReader::new(Cursor::new(export)));
1391        let mut importer = ArchiveImporter::load(reader, &key).await?;
1392        insert_importer(&mut importer, &alix2.context).await?;
1393
1394        let restored = alix2.db().find_group(&alix_group.group_id)?;
1395        assert!(
1396            restored.is_some(),
1397            "dictionary group missing from restored archive"
1398        );
1399
1400        // Presence is not enough: the metadata written before export
1401        // must round-trip through the archive, or per-field loss in
1402        // the exporter's dict read would go unnoticed.
1403        let restored_group = alix2.group(&alix_group.group_id)?;
1404        assert_eq!(restored_group.group_name()?, "post-migration name");
1405        assert_eq!(
1406            restored_group.group_description()?,
1407            "post-migration description"
1408        );
1409        assert_eq!(
1410            restored_group.group_image_url_square()?,
1411            "https://example.com/post-migration.png"
1412        );
1413
1414        let restored_second = alix2.group(&second_group.group_id)?;
1415        assert_eq!(restored_second.group_name()?, "second group name");
1416    }
1417}
1418
1419#[cfg(test)]
1420mod restored_tests;
1421
1422#[cfg(all(test, not(target_arch = "wasm32")))]
1423mod migration_tests;