core_crypto/mls/conversation/
renew.rs

1use core_crypto_keystore::entities::MlsEncryptionKeyPair;
2use openmls::prelude::{LeafNode, LeafNodeIndex, Proposal, QueuedProposal, Sender, StagedCommit};
3use openmls_traits::OpenMlsCryptoProvider;
4
5use mls_crypto_provider::MlsCryptoProvider;
6
7use super::{Error, Result};
8use crate::{
9    KeystoreError, RecursiveError,
10    prelude::{Client, MlsConversation, MlsProposalBundle},
11};
12
13/// Marker struct holding methods responsible for restoring (renewing) proposals (or pending commit)
14/// in case another commit has been accepted by the backend instead of ours
15pub(crate) struct Renew;
16
17impl Renew {
18    /// Renews proposals:
19    /// * in pending_proposals but not in valid commit
20    /// * in pending_commit but not in valid commit
21    ///
22    /// NB: we do not deal with partial commit (commit which do not contain all pending proposals)
23    /// because they cannot be created at the moment by core-crypto
24    ///
25    /// * `self_index` - own client [KeyPackageRef] in current MLS group
26    /// * `pending_proposals` - local pending proposals in group's proposal store
27    /// * `pending_commit` - local pending commit which is now invalid
28    /// * `valid_commit` - commit accepted by the backend which will now supersede our local pending commit
29    pub(crate) fn renew<'a>(
30        self_index: &LeafNodeIndex,
31        pending_proposals: impl Iterator<Item = QueuedProposal> + 'a,
32        pending_commit: Option<&'a StagedCommit>,
33        valid_commit: &'a StagedCommit,
34    ) -> (Vec<QueuedProposal>, bool) {
35        // indicates if we need to renew an update proposal.
36        // true only if we have an empty pending commit or the valid commit does not contain one of our update proposal
37        // otherwise, local orphan update proposal will be renewed regularly, without this flag
38        let mut needs_update = false;
39
40        let renewed_pending_proposals = if let Some(pending_commit) = pending_commit {
41            // present in pending commit but not in valid commit
42            let commit_proposals = pending_commit.queued_proposals().cloned().collect::<Vec<_>>();
43
44            // if our own pending commit is empty it means we were attempting to update
45            let empty_commit = commit_proposals.is_empty();
46
47            // does the valid commit contains one of our update proposal ?
48            let valid_commit_has_own_update_proposal = valid_commit.update_proposals().any(|p| match p.sender() {
49                Sender::Member(sender_index) => self_index == sender_index,
50                _ => false,
51            });
52
53            // do we need to renew the update or has it already been committed
54            needs_update = !valid_commit_has_own_update_proposal && empty_commit;
55
56            // local proposals present in local pending commit but not in valid commit
57            commit_proposals
58                .into_iter()
59                .filter_map(|p| Self::is_proposal_renewable(p, Some(valid_commit)))
60                .collect::<Vec<_>>()
61        } else {
62            // local pending proposals present locally but not in valid commit
63            pending_proposals
64                .filter_map(|p| Self::is_proposal_renewable(p, Some(valid_commit)))
65                .collect::<Vec<_>>()
66        };
67        (renewed_pending_proposals, needs_update)
68    }
69
70    /// A proposal has to be renewed if it is absent from supplied commit
71    fn is_proposal_renewable(proposal: QueuedProposal, commit: Option<&StagedCommit>) -> Option<QueuedProposal> {
72        if let Some(commit) = commit {
73            let in_commit = match proposal.proposal() {
74                Proposal::Add(add) => commit.add_proposals().any(|p| {
75                    let commits_identity = p.add_proposal().key_package().leaf_node().credential().identity();
76                    let proposal_identity = add.key_package().leaf_node().credential().identity();
77                    commits_identity == proposal_identity
78                }),
79                Proposal::Remove(remove) => commit
80                    .remove_proposals()
81                    .any(|p| p.remove_proposal().removed() == remove.removed()),
82                Proposal::Update(update) => commit
83                    .update_proposals()
84                    .any(|p| p.update_proposal().leaf_node() == update.leaf_node()),
85                _ => true,
86            };
87            if in_commit { None } else { Some(proposal) }
88        } else {
89            // if proposal is orphan (not present in commit)
90            Some(proposal)
91        }
92    }
93}
94
95impl MlsConversation {
96    /// Given the proposals to renew, actually restore them by using associated methods in [MlsGroup].
97    /// This will also add them to the local proposal store
98    pub(crate) async fn renew_proposals_for_current_epoch(
99        &mut self,
100        client: &Client,
101        backend: &MlsCryptoProvider,
102        proposals: impl Iterator<Item = QueuedProposal>,
103        needs_update: bool,
104    ) -> Result<Vec<MlsProposalBundle>> {
105        let mut bundle = vec![];
106        let is_external = |p: &QueuedProposal| matches!(p.sender(), Sender::External(_) | Sender::NewMemberProposal);
107        let proposals = proposals.filter(|p| !is_external(p));
108        for proposal in proposals {
109            let msg = match proposal.proposal {
110                Proposal::Add(add) => self.propose_add_member(client, backend, add.key_package.into()).await?,
111                Proposal::Remove(remove) => self.propose_remove_member(client, backend, remove.removed()).await?,
112                Proposal::Update(update) => self.renew_update(client, backend, Some(update.leaf_node())).await?,
113                _ => return Err(Error::ProposalVariantCannotBeRenewed),
114            };
115            bundle.push(msg);
116        }
117        if needs_update {
118            let proposal = self.renew_update(client, backend, None).await?;
119            bundle.push(proposal);
120        }
121        Ok(bundle)
122    }
123
124    /// Renews an update proposal by considering the explicit LeafNode supplied in the proposal
125    /// by applying it to the current own LeafNode.
126    /// At this point, we have already verified we are only operating on proposals created by self.
127    async fn renew_update(
128        &mut self,
129        client: &Client,
130        backend: &MlsCryptoProvider,
131        leaf_node: Option<&LeafNode>,
132    ) -> Result<MlsProposalBundle> {
133        if let Some(leaf_node) = leaf_node {
134            // Creating an update rekeys the LeafNode everytime. Hence we need to clear the previous
135            // encryption key from the keystore otherwise we would have a leak
136            backend
137                .key_store()
138                .remove::<MlsEncryptionKeyPair, _>(leaf_node.encryption_key().as_slice())
139                .await
140                .map_err(KeystoreError::wrap("removing mls encryption keypair"))?;
141        }
142
143        let mut leaf_node = leaf_node
144            .or_else(|| self.group.own_leaf())
145            .cloned()
146            .ok_or(Error::MlsGroupInvalidState("own_leaf is None"))?;
147
148        let sc = self.signature_scheme();
149        let ct = self.own_credential_type()?;
150        let cb = client
151            .find_most_recent_credential_bundle(sc, ct)
152            .await
153            .map_err(RecursiveError::mls_client("finding most recent credential bundle"))?;
154
155        leaf_node.set_credential_with_key(cb.to_mls_credential_with_key());
156
157        self.propose_explicit_self_update(client, backend, Some(leaf_node))
158            .await
159    }
160
161    pub(crate) fn self_pending_proposals(&self) -> impl Iterator<Item = &QueuedProposal> {
162        self.group
163            .pending_proposals()
164            .filter(|&p| matches!(p.sender(), Sender::Member(i) if i == &self.group.own_leaf_index()))
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use wasm_bindgen_test::*;
171
172    use crate::test_utils::*;
173
174    mod update {
175        use super::*;
176
177        #[apply(all_cred_cipher)]
178        #[wasm_bindgen_test]
179        pub async fn renewable_when_created_by_self(case: TestCase) {
180            run_test_with_client_ids(
181                case.clone(),
182                ["alice", "bob"],
183                move |[mut alice_central, bob_central]| {
184                    Box::pin(async move {
185                        let id = conversation_id();
186                        alice_central
187                            .context
188                            .new_conversation(&id, case.credential_type, case.cfg.clone())
189                            .await
190                            .unwrap();
191                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
192
193                        assert!(alice_central.pending_proposals(&id).await.is_empty());
194                        alice_central.context.new_update_proposal(&id).await.unwrap();
195                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
196
197                        // Bob hasn't Alice's proposal but creates a commit
198                        bob_central
199                            .context
200                            .conversation(&id)
201                            .await
202                            .unwrap()
203                            .update_key_material()
204                            .await
205                            .unwrap();
206                        let commit = bob_central.mls_transport.latest_commit().await;
207
208                        let proposals = alice_central
209                            .context
210                            .conversation(&id)
211                            .await
212                            .unwrap()
213                            .decrypt_message(commit.to_bytes().unwrap())
214                            .await
215                            .unwrap()
216                            .proposals;
217                        // Alice should renew the proposal because its hers
218                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
219                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
220
221                        // It should also renew the proposal when in pending_commit
222                        bob_central
223                            .context
224                            .conversation(&id)
225                            .await
226                            .unwrap()
227                            .update_key_material()
228                            .await
229                            .unwrap();
230                        let commit = bob_central.mls_transport.latest_commit().await;
231                        let proposals = alice_central
232                            .context
233                            .conversation(&id)
234                            .await
235                            .unwrap()
236                            .decrypt_message(commit.to_bytes().unwrap())
237                            .await
238                            .unwrap()
239                            .proposals;
240                        // Alice should renew the proposal because its hers
241                        // It should also replace existing one
242                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
243                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
244                    })
245                },
246            )
247            .await
248        }
249
250        #[apply(all_cred_cipher)]
251        #[wasm_bindgen_test]
252        pub async fn not_renewable_when_in_valid_commit(case: TestCase) {
253            run_test_with_client_ids(
254                case.clone(),
255                ["alice", "bob"],
256                move |[mut alice_central, bob_central]| {
257                    Box::pin(async move {
258                        let id = conversation_id();
259                        alice_central
260                            .context
261                            .new_conversation(&id, case.credential_type, case.cfg.clone())
262                            .await
263                            .unwrap();
264                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
265
266                        assert!(alice_central.pending_proposals(&id).await.is_empty());
267                        let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
268                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
269
270                        // Bob has Alice's update proposal
271                        bob_central
272                            .context
273                            .conversation(&id)
274                            .await
275                            .unwrap()
276                            .decrypt_message(proposal.to_bytes().unwrap())
277                            .await
278                            .unwrap();
279
280                        bob_central
281                            .context
282                            .conversation(&id)
283                            .await
284                            .unwrap()
285                            .update_key_material()
286                            .await
287                            .unwrap();
288                        let commit = bob_central.mls_transport.latest_commit().await;
289
290                        // Bob's commit has Alice's proposal
291                        let proposals = alice_central
292                            .context
293                            .conversation(&id)
294                            .await
295                            .unwrap()
296                            .decrypt_message(commit.to_bytes().unwrap())
297                            .await
298                            .unwrap()
299                            .proposals;
300                        // Alice proposal should not be renew as it was in valid commit
301                        assert!(alice_central.pending_proposals(&id).await.is_empty());
302                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
303
304                        let proposal = alice_central.context.new_update_proposal(&id).await.unwrap().proposal;
305                        bob_central
306                            .context
307                            .conversation(&id)
308                            .await
309                            .unwrap()
310                            .decrypt_message(proposal.to_bytes().unwrap())
311                            .await
312                            .unwrap();
313                        bob_central
314                            .context
315                            .conversation(&id)
316                            .await
317                            .unwrap()
318                            .update_key_material()
319                            .await
320                            .unwrap();
321                        let commit = bob_central.mls_transport.latest_commit().await;
322                        let proposals = alice_central
323                            .context
324                            .conversation(&id)
325                            .await
326                            .unwrap()
327                            .decrypt_message(commit.to_bytes().unwrap())
328                            .await
329                            .unwrap()
330                            .proposals;
331                        // Alice should not be renew as it was in valid commit
332                        assert!(alice_central.pending_proposals(&id).await.is_empty());
333                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
334                    })
335                },
336            )
337            .await
338        }
339
340        #[apply(all_cred_cipher)]
341        #[wasm_bindgen_test]
342        pub async fn not_renewable_by_ref(case: TestCase) {
343            run_test_with_client_ids(
344                case.clone(),
345                ["alice", "bob", "charlie"],
346                move |[mut alice_central, bob_central, charlie_central]| {
347                    Box::pin(async move {
348                        let id = conversation_id();
349                        alice_central
350                            .context
351                            .new_conversation(&id, case.credential_type, case.cfg.clone())
352                            .await
353                            .unwrap();
354                        alice_central
355                            .invite_all(&case, &id, [&bob_central, &charlie_central])
356                            .await
357                            .unwrap();
358
359                        let proposal = bob_central.context.new_update_proposal(&id).await.unwrap().proposal;
360                        assert!(alice_central.pending_proposals(&id).await.is_empty());
361                        alice_central
362                            .context
363                            .conversation(&id)
364                            .await
365                            .unwrap()
366                            .decrypt_message(proposal.to_bytes().unwrap())
367                            .await
368                            .unwrap();
369                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
370
371                        // Charlie does not have other proposals, it creates a commit
372                        charlie_central
373                            .context
374                            .conversation(&id)
375                            .await
376                            .unwrap()
377                            .update_key_material()
378                            .await
379                            .unwrap();
380                        let commit = charlie_central.mls_transport.latest_commit().await;
381                        let proposals = alice_central
382                            .context
383                            .conversation(&id)
384                            .await
385                            .unwrap()
386                            .decrypt_message(commit.to_bytes().unwrap())
387                            .await
388                            .unwrap()
389                            .proposals;
390                        // Alice should not renew Bob's update proposal
391                        assert!(alice_central.pending_proposals(&id).await.is_empty());
392                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
393                    })
394                },
395            )
396            .await
397        }
398    }
399
400    mod add {
401        use super::*;
402
403        #[apply(all_cred_cipher)]
404        #[wasm_bindgen_test]
405        pub async fn not_renewable_when_valid_commit_adds_same(case: TestCase) {
406            run_test_with_client_ids(
407                case.clone(),
408                ["alice", "bob", "charlie"],
409                move |[mut alice_central, bob_central, charlie_central]| {
410                    Box::pin(async move {
411                        let id = conversation_id();
412                        alice_central
413                            .context
414                            .new_conversation(&id, case.credential_type, case.cfg.clone())
415                            .await
416                            .unwrap();
417                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
418
419                        let charlie_kp = charlie_central.get_one_key_package(&case).await;
420                        assert!(alice_central.pending_proposals(&id).await.is_empty());
421                        alice_central.context.new_add_proposal(&id, charlie_kp).await.unwrap();
422                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
423
424                        let charlie = charlie_central.rand_key_package(&case).await;
425                        bob_central
426                            .context
427                            .conversation(&id)
428                            .await
429                            .unwrap()
430                            .add_members(vec![charlie])
431                            .await
432                            .unwrap();
433                        let commit = bob_central.mls_transport.latest_commit().await;
434                        let proposals = alice_central
435                            .context
436                            .conversation(&id)
437                            .await
438                            .unwrap()
439                            .decrypt_message(commit.to_bytes().unwrap())
440                            .await
441                            .unwrap()
442                            .proposals;
443                        // Alice proposal is not renewed since she also wanted to add Charlie
444                        assert!(alice_central.pending_proposals(&id).await.is_empty());
445                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
446                    })
447                },
448            )
449            .await
450        }
451
452        #[apply(all_cred_cipher)]
453        #[wasm_bindgen_test]
454        pub async fn not_renewable_in_pending_commit_when_valid_commit_adds_same(case: TestCase) {
455            run_test_with_client_ids(
456                case.clone(),
457                ["alice", "bob", "charlie"],
458                move |[mut alice_central, bob_central, charlie_central]| {
459                    Box::pin(async move {
460                        let id = conversation_id();
461                        alice_central
462                            .context
463                            .new_conversation(&id, case.credential_type, case.cfg.clone())
464                            .await
465                            .unwrap();
466                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
467
468                        let charlie_kp = charlie_central.get_one_key_package(&case).await;
469                        assert!(alice_central.pending_proposals(&id).await.is_empty());
470                        alice_central.context.new_add_proposal(&id, charlie_kp).await.unwrap();
471                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
472
473                        // Here Alice also creates a commit
474                        alice_central.commit_pending_proposals_unmerged(&id).await;
475                        assert!(alice_central.pending_commit(&id).await.is_some());
476
477                        let charlie = charlie_central.rand_key_package(&case).await;
478                        bob_central
479                            .context
480                            .conversation(&id)
481                            .await
482                            .unwrap()
483                            .add_members(vec![charlie])
484                            .await
485                            .unwrap();
486                        let commit = bob_central.mls_transport.latest_commit().await;
487                        let proposals = alice_central
488                            .context
489                            .conversation(&id)
490                            .await
491                            .unwrap()
492                            .decrypt_message(commit.to_bytes().unwrap())
493                            .await
494                            .unwrap()
495                            .proposals;
496                        // Alice proposal is not renewed since she also wanted to add Charlie
497                        assert!(alice_central.pending_proposals(&id).await.is_empty());
498                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
499                    })
500                },
501            )
502            .await
503        }
504
505        #[apply(all_cred_cipher)]
506        #[wasm_bindgen_test]
507        pub async fn not_renewable_by_ref(case: TestCase) {
508            run_test_with_client_ids(
509                case.clone(),
510                ["alice", "bob", "charlie", "debbie"],
511                move |[mut alice_central, bob_central, charlie_central, debbie_central]| {
512                    Box::pin(async move {
513                        let id = conversation_id();
514                        alice_central
515                            .context
516                            .new_conversation(&id, case.credential_type, case.cfg.clone())
517                            .await
518                            .unwrap();
519                        alice_central
520                            .invite_all(&case, &id, [&bob_central, &charlie_central])
521                            .await
522                            .unwrap();
523
524                        // Bob will propose adding Debbie
525                        let debbie_kp = debbie_central.get_one_key_package(&case).await;
526                        let proposal = bob_central
527                            .context
528                            .new_add_proposal(&id, debbie_kp)
529                            .await
530                            .unwrap()
531                            .proposal;
532                        alice_central
533                            .context
534                            .conversation(&id)
535                            .await
536                            .unwrap()
537                            .decrypt_message(proposal.to_bytes().unwrap())
538                            .await
539                            .unwrap();
540                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
541
542                        // But Charlie will commit meanwhile
543                        charlie_central
544                            .context
545                            .conversation(&id)
546                            .await
547                            .unwrap()
548                            .update_key_material()
549                            .await
550                            .unwrap();
551                        let commit = charlie_central.mls_transport.latest_commit().await;
552                        let proposals = alice_central
553                            .context
554                            .conversation(&id)
555                            .await
556                            .unwrap()
557                            .decrypt_message(commit.to_bytes().unwrap())
558                            .await
559                            .unwrap()
560                            .proposals;
561                        // which Alice should not renew since it's not hers
562                        assert!(alice_central.pending_proposals(&id).await.is_empty());
563                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
564                    })
565                },
566            )
567            .await
568        }
569
570        #[apply(all_cred_cipher)]
571        #[wasm_bindgen_test]
572        pub async fn renewable_when_valid_commit_doesnt_adds_same(case: TestCase) {
573            run_test_with_client_ids(
574                case.clone(),
575                ["alice", "bob", "charlie"],
576                move |[mut alice_central, bob_central, charlie_central]| {
577                    Box::pin(async move {
578                        let id = conversation_id();
579                        alice_central
580                            .context
581                            .new_conversation(&id, case.credential_type, case.cfg.clone())
582                            .await
583                            .unwrap();
584                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
585
586                        // Alice proposes adding Charlie
587                        let charlie_kp = charlie_central.get_one_key_package(&case).await;
588                        assert!(alice_central.pending_proposals(&id).await.is_empty());
589                        alice_central.context.new_add_proposal(&id, charlie_kp).await.unwrap();
590                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
591
592                        // But meanwhile Bob will create a commit without Alice's proposal
593                        bob_central
594                            .context
595                            .conversation(&id)
596                            .await
597                            .unwrap()
598                            .update_key_material()
599                            .await
600                            .unwrap();
601                        let commit = bob_central.mls_transport.latest_commit().await;
602                        let proposals = alice_central
603                            .context
604                            .conversation(&id)
605                            .await
606                            .unwrap()
607                            .decrypt_message(commit.to_bytes().unwrap())
608                            .await
609                            .unwrap()
610                            .proposals;
611                        // So Alice proposal should be renewed
612                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
613                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
614
615                        // And same should happen when proposal is in pending commit
616                        alice_central.commit_pending_proposals_unmerged(&id).await;
617                        assert!(alice_central.pending_commit(&id).await.is_some());
618                        bob_central
619                            .context
620                            .conversation(&id)
621                            .await
622                            .unwrap()
623                            .update_key_material()
624                            .await
625                            .unwrap();
626                        let commit = bob_central.mls_transport.latest_commit().await;
627                        let proposals = alice_central
628                            .context
629                            .conversation(&id)
630                            .await
631                            .unwrap()
632                            .decrypt_message(commit.to_bytes().unwrap())
633                            .await
634                            .unwrap()
635                            .proposals;
636                        // So Alice proposal should also be renewed
637                        // It should also replace existing one
638                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
639                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
640                    })
641                },
642            )
643            .await
644        }
645
646        #[apply(all_cred_cipher)]
647        #[wasm_bindgen_test]
648        pub async fn renews_pending_commit_when_valid_commit_doesnt_add_same(case: TestCase) {
649            run_test_with_client_ids(
650                case.clone(),
651                ["alice", "bob"],
652                move |[mut alice_central, bob_central]| {
653                    Box::pin(async move {
654                        let id = conversation_id();
655                        alice_central
656                            .context
657                            .new_conversation(&id, case.credential_type, case.cfg.clone())
658                            .await
659                            .unwrap();
660                        alice_central.invite_all(&case, &id, [&bob_central]).await.unwrap();
661
662                        // Alice commits adding Charlie
663                        alice_central.create_unmerged_commit(&id).await;
664                        assert!(alice_central.pending_commit(&id).await.is_some());
665
666                        // But meanwhile Bob will create a commit
667                        bob_central
668                            .context
669                            .conversation(&id)
670                            .await
671                            .unwrap()
672                            .update_key_material()
673                            .await
674                            .unwrap();
675                        let commit = bob_central.mls_transport.latest_commit().await;
676                        let proposals = alice_central
677                            .context
678                            .conversation(&id)
679                            .await
680                            .unwrap()
681                            .decrypt_message(commit.to_bytes().unwrap())
682                            .await
683                            .unwrap()
684                            .proposals;
685                        // So Alice proposal should be renewed
686                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
687                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
688                    })
689                },
690            )
691            .await
692        }
693    }
694
695    mod remove {
696        use super::*;
697
698        #[apply(all_cred_cipher)]
699        #[wasm_bindgen_test]
700        pub async fn not_renewable_when_valid_commit_removes_same(case: TestCase) {
701            run_test_with_client_ids(
702                case.clone(),
703                ["alice", "bob", "charlie"],
704                move |[mut alice_central, bob_central, charlie_central]| {
705                    Box::pin(async move {
706                        let id = conversation_id();
707                        alice_central
708                            .context
709                            .new_conversation(&id, case.credential_type, case.cfg.clone())
710                            .await
711                            .unwrap();
712                        alice_central
713                            .invite_all(&case, &id, [&bob_central, &charlie_central])
714                            .await
715                            .unwrap();
716
717                        assert!(alice_central.pending_proposals(&id).await.is_empty());
718                        alice_central
719                            .context
720                            .new_remove_proposal(&id, charlie_central.get_client_id().await)
721                            .await
722                            .unwrap();
723                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
724
725                        bob_central
726                            .context
727                            .conversation(&id)
728                            .await
729                            .unwrap()
730                            .remove_members(&[charlie_central.get_client_id().await])
731                            .await
732                            .unwrap();
733                        let commit = bob_central.mls_transport.latest_commit().await;
734                        let proposals = alice_central
735                            .context
736                            .conversation(&id)
737                            .await
738                            .unwrap()
739                            .decrypt_message(commit.to_bytes().unwrap())
740                            .await
741                            .unwrap()
742                            .proposals;
743                        // Remove proposal is not renewed since commit does same
744                        assert!(alice_central.pending_proposals(&id).await.is_empty());
745                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
746                    })
747                },
748            )
749            .await
750        }
751
752        #[apply(all_cred_cipher)]
753        #[wasm_bindgen_test]
754        pub async fn not_renewable_by_ref(case: TestCase) {
755            run_test_with_client_ids(
756                case.clone(),
757                ["alice", "bob", "charlie"],
758                move |[mut alice_central, bob_central, charlie_central]| {
759                    Box::pin(async move {
760                        let id = conversation_id();
761                        alice_central
762                            .context
763                            .new_conversation(&id, case.credential_type, case.cfg.clone())
764                            .await
765                            .unwrap();
766                        alice_central
767                            .invite_all(&case, &id, [&bob_central, &charlie_central])
768                            .await
769                            .unwrap();
770
771                        let proposal = bob_central
772                            .context
773                            .new_remove_proposal(&id, charlie_central.get_client_id().await)
774                            .await
775                            .unwrap()
776                            .proposal;
777                        assert!(alice_central.pending_proposals(&id).await.is_empty());
778                        alice_central
779                            .context
780                            .conversation(&id)
781                            .await
782                            .unwrap()
783                            .decrypt_message(proposal.to_bytes().unwrap())
784                            .await
785                            .unwrap();
786                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
787
788                        charlie_central
789                            .context
790                            .conversation(&id)
791                            .await
792                            .unwrap()
793                            .update_key_material()
794                            .await
795                            .unwrap();
796                        let commit = charlie_central.mls_transport.latest_commit().await;
797                        let proposals = alice_central
798                            .context
799                            .conversation(&id)
800                            .await
801                            .unwrap()
802                            .decrypt_message(commit.to_bytes().unwrap())
803                            .await
804                            .unwrap()
805                            .proposals;
806                        // Remove proposal is not renewed since by ref
807                        assert!(alice_central.pending_proposals(&id).await.is_empty());
808                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
809                    })
810                },
811            )
812            .await
813        }
814
815        #[apply(all_cred_cipher)]
816        #[wasm_bindgen_test]
817        pub async fn renewable_when_valid_commit_doesnt_remove_same(case: TestCase) {
818            run_test_with_client_ids(
819                case.clone(),
820                ["alice", "bob", "charlie", "debbie"],
821                move |[mut alice_central, bob_central, charlie_central, debbie_central]| {
822                    Box::pin(async move {
823                        let id = conversation_id();
824                        alice_central
825                            .context
826                            .new_conversation(&id, case.credential_type, case.cfg.clone())
827                            .await
828                            .unwrap();
829                        alice_central
830                            .invite_all(&case, &id, [&bob_central, &charlie_central, &debbie_central])
831                            .await
832                            .unwrap();
833
834                        // Alice wants to remove Charlie
835                        assert!(alice_central.pending_proposals(&id).await.is_empty());
836                        alice_central
837                            .context
838                            .new_remove_proposal(&id, charlie_central.get_client_id().await)
839                            .await
840                            .unwrap();
841                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
842
843                        // Whereas Bob wants to remove Debbie
844                        bob_central
845                            .context
846                            .conversation(&id)
847                            .await
848                            .unwrap()
849                            .remove_members(&[debbie_central.get_client_id().await])
850                            .await
851                            .unwrap();
852                        let commit = bob_central.mls_transport.latest_commit().await;
853                        let proposals = alice_central
854                            .context
855                            .conversation(&id)
856                            .await
857                            .unwrap()
858                            .decrypt_message(commit.to_bytes().unwrap())
859                            .await
860                            .unwrap()
861                            .proposals;
862                        // Remove is renewed since valid commit removes another
863                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
864                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
865                    })
866                },
867            )
868            .await
869        }
870
871        #[apply(all_cred_cipher)]
872        #[wasm_bindgen_test]
873        pub async fn renews_pending_commit_when_commit_doesnt_remove_same(case: TestCase) {
874            run_test_with_client_ids(
875                case.clone(),
876                ["alice", "bob", "charlie", "debbie"],
877                move |[mut alice_central, bob_central, charlie_central, debbie_central]| {
878                    Box::pin(async move {
879                        let id = conversation_id();
880                        alice_central
881                            .context
882                            .new_conversation(&id, case.credential_type, case.cfg.clone())
883                            .await
884                            .unwrap();
885                        alice_central
886                            .invite_all(&case, &id, [&bob_central, &charlie_central, &debbie_central])
887                            .await
888                            .unwrap();
889
890                        // Alice wants to remove Charlie
891                        alice_central
892                            .context
893                            .new_remove_proposal(&id, charlie_central.get_client_id().await)
894                            .await
895                            .unwrap();
896                        alice_central.commit_pending_proposals_unmerged(&id).await;
897                        assert!(alice_central.pending_commit(&id).await.is_some());
898
899                        // Whereas Bob wants to remove Debbie
900                        bob_central
901                            .context
902                            .conversation(&id)
903                            .await
904                            .unwrap()
905                            .remove_members(&[debbie_central.get_client_id().await])
906                            .await
907                            .unwrap();
908                        let commit = bob_central.mls_transport.latest_commit().await;
909                        let proposals = alice_central
910                            .context
911                            .conversation(&id)
912                            .await
913                            .unwrap()
914                            .decrypt_message(commit.to_bytes().unwrap())
915                            .await
916                            .unwrap()
917                            .proposals;
918                        // Remove is renewed since valid commit removes another
919                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
920                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
921                    })
922                },
923            )
924            .await
925        }
926
927        #[apply(all_cred_cipher)]
928        #[wasm_bindgen_test]
929        pub async fn renews_pending_commit_from_proposal_when_commit_doesnt_remove_same(case: TestCase) {
930            run_test_with_client_ids(
931                case.clone(),
932                ["alice", "bob", "charlie", "debbie"],
933                move |[mut alice_central, bob_central, charlie_central, debbie_central]| {
934                    Box::pin(async move {
935                        let id = conversation_id();
936                        alice_central
937                            .context
938                            .new_conversation(&id, case.credential_type, case.cfg.clone())
939                            .await
940                            .unwrap();
941                        alice_central
942                            .invite_all(&case, &id, [&bob_central, &charlie_central, &debbie_central])
943                            .await
944                            .unwrap();
945
946                        // Alice wants to remove Charlie
947                        alice_central
948                            .context
949                            .new_remove_proposal(&id, charlie_central.get_client_id().await)
950                            .await
951                            .unwrap();
952                        alice_central.commit_pending_proposals_unmerged(&id).await;
953
954                        // Whereas Bob wants to remove Debbie
955                        bob_central
956                            .context
957                            .conversation(&id)
958                            .await
959                            .unwrap()
960                            .remove_members(&[debbie_central.get_client_id().await])
961                            .await
962                            .unwrap();
963                        let commit = bob_central.mls_transport.latest_commit().await;
964                        let proposals = alice_central
965                            .context
966                            .conversation(&id)
967                            .await
968                            .unwrap()
969                            .decrypt_message(commit.to_bytes().unwrap())
970                            .await
971                            .unwrap()
972                            .proposals;
973                        // Remove is renewed since valid commit removes another
974                        assert_eq!(alice_central.pending_proposals(&id).await.len(), 1);
975                        assert_eq!(proposals.len(), alice_central.pending_proposals(&id).await.len());
976                    })
977                },
978            )
979            .await
980        }
981    }
982}