Skip to main content

river_core/
room_state.rs

1pub mod ban;
2pub mod configuration;
3pub mod content;
4pub mod direct_messages;
5pub mod dm_body;
6pub mod identity;
7pub mod member;
8pub mod member_info;
9pub mod message;
10pub mod privacy;
11pub mod secret;
12pub mod upgrade;
13pub mod version;
14
15use crate::room_state::ban::BansV1;
16use crate::room_state::configuration::AuthorizedConfigurationV1;
17use crate::room_state::direct_messages::DirectMessagesV1;
18use crate::room_state::member::{MemberId, MembersV1};
19use crate::room_state::member_info::MemberInfoV1;
20use crate::room_state::message::MessagesV1;
21use crate::room_state::secret::RoomSecretsV1;
22use crate::room_state::upgrade::OptionalUpgradeV1;
23use crate::room_state::version::StateVersion;
24use ed25519_dalek::VerifyingKey;
25use freenet_scaffold_macro::composable;
26use serde::{Deserialize, Serialize};
27use std::collections::HashSet;
28
29#[composable(post_apply_delta = "post_apply_cleanup")]
30#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)]
31pub struct ChatRoomStateV1 {
32    // WARNING: The order of these fields is important for the purposes of the #[composable] macro.
33    // `configuration` must be first, followed by `bans`, `members`, `member_info`, `secrets`,
34    // and then `recent_messages`.
35    // This is due to interdependencies between the fields and the order in which they must be applied in
36    // the `apply_delta` function. DO NOT reorder fields without fully understanding the implications.
37    /// Configures things like maximum message length, can be updated by the owner.
38    pub configuration: AuthorizedConfigurationV1,
39
40    /// A list of recently banned members, a banned member can't be present in the
41    /// members list and will be removed from it ifc necessary.
42    pub bans: BansV1,
43
44    /// The members in the chat room along with who invited them
45    pub members: MembersV1,
46
47    /// Metadata about members like their nickname, can be updated by members themselves.
48    pub member_info: MemberInfoV1,
49
50    /// Secret distribution for private rooms. Must come before recent_messages so message
51    /// validation can check secret version consistency.
52    pub secrets: RoomSecretsV1,
53
54    /// The most recent messages in the chat room, the number is limited by the room configuration.
55    pub recent_messages: MessagesV1,
56
57    /// In-room encrypted direct messages between members (#230 Phase 1).
58    /// `#[serde(default)]` keeps states written before this field was added
59    /// backwards-compatible.
60    #[serde(default)]
61    pub direct_messages: DirectMessagesV1,
62
63    /// If this contract has been replaced by a new contract this will contain the new contract address.
64    /// This can only be set by the owner.
65    pub upgrade: OptionalUpgradeV1,
66
67    /// State format version for migration compatibility.
68    /// Defaults to 0 for backward compatibility with states created before versioning.
69    #[serde(default)]
70    pub version: StateVersion,
71}
72
73impl ChatRoomStateV1 {
74    /// Post-apply cleanup: prune members who have no recent messages, clean up
75    /// member_info for pruned members, remove orphaned bans, and sweep
76    /// direct messages whose participants are no longer in the room.
77    ///
78    /// Members are kept if they have at least one message in recent_messages,
79    /// are a sender/recipient of a currently-held direct message (see
80    /// [`crate::room_state::direct_messages::DirectMessagesV1::active_participants`]),
81    /// or are in the invite chain of someone who qualifies. The owner is
82    /// never in the members list (they're implicit via parameters).
83    ///
84    /// Bans are only removed if the banner was themselves BANNED (orphaned ban).
85    /// If the banner was merely pruned for inactivity, their bans persist.
86    ///
87    /// Direct-message sweep: after pruning, any DM whose sender or
88    /// recipient is now non-member or banned is dropped. Without this,
89    /// adding a ban for a DM participant would silently make every
90    /// peer's verify fail, and members referenced only by a DM would be
91    /// pruned (orphaning their DMs). See
92    /// `direct_messages.rs` module docs, "Interaction with bans".
93    pub fn post_apply_cleanup(&mut self, parameters: &ChatRoomParametersV1) -> Result<(), String> {
94        let owner_id = MemberId::from(&parameters.owner);
95
96        // 1. Collect message author IDs + DM participants + secret recipients.
97        //
98        // Secret recipients (i.e. members for whom the owner has issued an
99        // `encrypted_secrets` blob AT THE CURRENT VERSION) are exempt
100        // from inactivity-prune. The owner explicitly chose to issue
101        // them a per-version room secret, so the owner clearly considers
102        // them a member — and post_apply cleanup running on an
103        // invitee's first state ingestion (which arrives before the
104        // invitee has authored any join_event) must not silently delete
105        // that membership. See issue #110 / Bug #3 PR B (Ivvor
106        // 2026-05-17).
107        //
108        // The exemption is restricted to recipients at `current_version`
109        // so cleanup still prunes genuinely-inactive members whose
110        // blobs are only present at older versions (a member who joined,
111        // received v0, never authored anything, and was never re-issued
112        // a blob at v1+ is "stale" by the same definition as a member
113        // who joined and never authored). Without this scoping the
114        // exemption would keep every ever-recipient + their entire
115        // invite chain ancestor set exempt from cleanup forever,
116        // defeating the prune. See IMPORTANT item #5 on PR #272
117        // review round 2.
118        let message_authors: HashSet<MemberId> = self
119            .recent_messages
120            .messages
121            .iter()
122            .map(|m| m.message.author)
123            .collect();
124        let dm_participants: HashSet<MemberId> = self.direct_messages.active_participants();
125        let current_secret_version = self.secrets.current_version;
126        let secret_recipients: HashSet<MemberId> = self
127            .secrets
128            .encrypted_secrets
129            .iter()
130            .filter(|s| s.secret.secret_version == current_secret_version)
131            .map(|s| s.secret.member_id)
132            .collect();
133
134        // 2. Compute required members: authors + DM participants + secret
135        //    recipients + their invite chains.
136        let required_ids = {
137            let members_by_id = self.members.members_by_member_id();
138            let mut required_ids: HashSet<MemberId> = HashSet::new();
139
140            for author_id in &message_authors {
141                if *author_id != owner_id && members_by_id.contains_key(author_id) {
142                    required_ids.insert(*author_id);
143                }
144            }
145
146            for participant_id in &dm_participants {
147                if *participant_id != owner_id && members_by_id.contains_key(participant_id) {
148                    required_ids.insert(*participant_id);
149                }
150            }
151
152            for recipient_id in &secret_recipients {
153                if *recipient_id != owner_id && members_by_id.contains_key(recipient_id) {
154                    required_ids.insert(*recipient_id);
155                }
156            }
157
158            // Walk invite chains upward, adding all ancestors (stop at owner)
159            let mut to_process: Vec<MemberId> = required_ids.iter().cloned().collect();
160            while let Some(member_id) = to_process.pop() {
161                if let Some(member) = members_by_id.get(&member_id) {
162                    let inviter_id = member.member.invited_by;
163                    if inviter_id != owner_id && !required_ids.contains(&inviter_id) {
164                        required_ids.insert(inviter_id);
165                        to_process.push(inviter_id);
166                    }
167                }
168            }
169
170            required_ids
171        };
172
173        // 3. Prune members not in required set
174        self.members
175            .members
176            .retain(|m| required_ids.contains(&m.member.id()));
177
178        // 4. Clean member_info for pruned members
179        self.member_info.member_info.retain(|info| {
180            info.member_info.member_id == owner_id
181                || required_ids.contains(&info.member_info.member_id)
182        });
183
184        // 5. Clean orphaned bans: only remove if banner was BANNED (not just pruned)
185        // A ban is orphaned when:
186        // - The banner is not the owner AND
187        // - The banner is not in the current members list AND
188        // - The banner IS in the banned users set (i.e., they were banned, not pruned)
189        let banned_user_ids: HashSet<MemberId> =
190            self.bans.0.iter().map(|b| b.ban.banned_user).collect();
191        let current_member_ids: HashSet<MemberId> =
192            self.members.members.iter().map(|m| m.member.id()).collect();
193
194        self.bans.0.retain(|ban| {
195            // Keep if: banner is owner, OR banner is still a member, OR banner is NOT banned
196            // (not banned = pruned for inactivity, their bans should persist)
197            ban.banned_by == owner_id
198                || current_member_ids.contains(&ban.banned_by)
199                || !banned_user_ids.contains(&ban.banned_by)
200        });
201
202        // 6. Sweep DMs whose participants are no longer current members
203        //    or are banned. Without this, a fresh ban (or member-prune)
204        //    would leave the DMs in state but break `verify` because the
205        //    sender/recipient can no longer be resolved.
206        let banned_user_ids_for_sweep: HashSet<MemberId> =
207            self.bans.0.iter().map(|b| b.ban.banned_user).collect();
208        let active_member_ids_for_sweep: HashSet<MemberId> =
209            self.members.members.iter().map(|m| m.member.id()).collect();
210        self.direct_messages.sweep_after_membership_change(
211            owner_id,
212            &active_member_ids_for_sweep,
213            &banned_user_ids_for_sweep,
214        );
215
216        // 7. Re-sort for deterministic ordering
217        self.members.members.sort_by_key(|m| m.member.id());
218        self.member_info
219            .member_info
220            .sort_by_key(|info| info.member_info.member_id);
221
222        Ok(())
223    }
224}
225
226#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)]
227pub struct ChatRoomParametersV1 {
228    pub owner: VerifyingKey,
229}
230
231impl ChatRoomParametersV1 {
232    pub fn owner_id(&self) -> MemberId {
233        self.owner.into()
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::room_state::ban::{AuthorizedUserBan, UserBan};
241    use crate::room_state::configuration::Configuration;
242    use crate::room_state::member::{AuthorizedMember, Member};
243    use crate::room_state::member_info::{AuthorizedMemberInfo, MemberInfo};
244    use crate::room_state::message::{AuthorizedMessageV1, MessageV1, RoomMessageBody};
245    use ed25519_dalek::SigningKey;
246    use std::fmt::Debug;
247    use std::time::SystemTime;
248
249    #[test]
250    fn test_state() {
251        let (state, parameters, owner_signing_key) = create_empty_chat_room_state();
252
253        assert!(
254            state.verify(&state, &parameters).is_ok(),
255            "Empty state should verify"
256        );
257
258        // Test that the configuration can be updated
259        let mut new_cfg = state.configuration.configuration.clone();
260        new_cfg.configuration_version += 1;
261        new_cfg.max_recent_messages = 10; // Change from default of 100 to 10
262        let new_cfg = AuthorizedConfigurationV1::new(new_cfg, &owner_signing_key);
263
264        let mut cfg_modified_state = state.clone();
265        cfg_modified_state.configuration = new_cfg;
266        test_apply_delta(state.clone(), cfg_modified_state, &parameters);
267    }
268
269    fn test_apply_delta<CS>(orig_state: CS, modified_state: CS, parameters: &CS::Parameters)
270    where
271        CS: ComposableState<ParentState = CS> + Clone + PartialEq + Debug,
272    {
273        let orig_verify_result = orig_state.verify(&orig_state, parameters);
274        assert!(
275            orig_verify_result.is_ok(),
276            "Original state verification failed: {:?}",
277            orig_verify_result.err()
278        );
279
280        let modified_verify_result = modified_state.verify(&modified_state, parameters);
281        assert!(
282            modified_verify_result.is_ok(),
283            "Modified state verification failed: {:?}",
284            modified_verify_result.err()
285        );
286
287        let delta = modified_state.delta(
288            &orig_state,
289            parameters,
290            &orig_state.summarize(&orig_state, parameters),
291        );
292
293        println!("Delta: {:?}", delta);
294
295        let mut new_state = orig_state.clone();
296        let apply_delta_result = new_state.apply_delta(&orig_state, parameters, &delta);
297        assert!(
298            apply_delta_result.is_ok(),
299            "Applying delta failed: {:?}",
300            apply_delta_result.err()
301        );
302
303        assert_eq!(new_state, modified_state);
304    }
305    fn create_empty_chat_room_state() -> (ChatRoomStateV1, ChatRoomParametersV1, SigningKey) {
306        // Create a test room_state with a single member and two messages, one written by
307        // the owner and one by the member - the member must be invited by the owner
308        let rng = &mut rand::thread_rng();
309        let owner_signing_key = SigningKey::generate(rng);
310        let owner_verifying_key = owner_signing_key.verifying_key();
311
312        let config = AuthorizedConfigurationV1::new(Configuration::default(), &owner_signing_key);
313
314        (
315            ChatRoomStateV1 {
316                configuration: config,
317                bans: BansV1::default(),
318                members: MembersV1::default(),
319                member_info: MemberInfoV1::default(),
320                secrets: RoomSecretsV1::default(),
321                recent_messages: MessagesV1::default(),
322                upgrade: OptionalUpgradeV1(None),
323                ..Default::default()
324            },
325            ChatRoomParametersV1 {
326                owner: owner_verifying_key,
327            },
328            owner_signing_key,
329        )
330    }
331
332    /// Regression test: when a member who issued bans is subsequently banned themselves,
333    /// their bans become orphaned (banning member no longer in members list and not owner).
334    /// The post_apply_delta hook post_apply_cleanup must remove these to prevent verify() failure.
335    /// See: technic corrupted state incident (Feb 2026)
336    #[test]
337    fn test_orphaned_ban_cleanup_after_cascade_removal() {
338        let rng = &mut rand::thread_rng();
339
340        // Create owner
341        let owner_sk = SigningKey::generate(rng);
342        let owner_vk = owner_sk.verifying_key();
343        let owner_id = MemberId::from(&owner_vk);
344        let params = ChatRoomParametersV1 { owner: owner_vk };
345
346        // Configuration allowing bans and members
347        let config = Configuration {
348            max_user_bans: 10,
349            max_members: 10,
350            ..Default::default()
351        };
352        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
353
354        // Create member A (invited by owner) and member B (invited by A)
355        let a_sk = SigningKey::generate(rng);
356        let a_vk = a_sk.verifying_key();
357        let a_id = MemberId::from(&a_vk);
358
359        let b_sk = SigningKey::generate(rng);
360        let b_vk = b_sk.verifying_key();
361        let b_id = MemberId::from(&b_vk);
362
363        let member_a = AuthorizedMember::new(
364            Member {
365                owner_member_id: owner_id,
366                invited_by: owner_id,
367                member_vk: a_vk,
368            },
369            &owner_sk,
370        );
371
372        // A bans B (authorized because A is in B's invite chain)
373        let ban_b_by_a = AuthorizedUserBan::new(
374            UserBan {
375                owner_member_id: owner_id,
376                banned_at: std::time::SystemTime::now(),
377                banned_user: b_id,
378            },
379            a_id,
380            &a_sk,
381        );
382
383        // Initial state: A is a member, B already removed (ban took effect)
384        let initial_state = ChatRoomStateV1 {
385            configuration: auth_config.clone(),
386            bans: BansV1(vec![ban_b_by_a.clone()]),
387            members: MembersV1 {
388                members: vec![member_a.clone()],
389            },
390            ..Default::default()
391        };
392
393        assert!(
394            initial_state.verify(&initial_state, &params).is_ok(),
395            "Initial state should verify: {:?}",
396            initial_state.verify(&initial_state, &params)
397        );
398
399        // Now owner bans A — this will cascade-remove A from members,
400        // making A's ban of B orphaned (A is no longer in members and not owner)
401        let ban_a_by_owner = AuthorizedUserBan::new(
402            UserBan {
403                owner_member_id: owner_id,
404                banned_at: std::time::SystemTime::now() + std::time::Duration::from_secs(1),
405                banned_user: a_id,
406            },
407            owner_id,
408            &owner_sk,
409        );
410
411        // Modified state for delta computation: add owner's ban of A
412        let modified_for_delta = ChatRoomStateV1 {
413            configuration: auth_config,
414            bans: BansV1(vec![ban_b_by_a.clone(), ban_a_by_owner.clone()]),
415            members: MembersV1 {
416                members: vec![member_a.clone()],
417            },
418            ..Default::default()
419        };
420
421        // Compute and apply delta
422        let summary = initial_state.summarize(&initial_state, &params);
423        let delta = modified_for_delta.delta(&initial_state, &params, &summary);
424
425        let mut result_state = initial_state.clone();
426        let apply_result = result_state.apply_delta(&initial_state, &params, &delta);
427        assert!(
428            apply_result.is_ok(),
429            "apply_delta should succeed: {:?}",
430            apply_result
431        );
432
433        // A should be removed (banned by owner)
434        assert!(
435            result_state.members.members.is_empty(),
436            "A should be removed from members: {:?}",
437            result_state.members.members
438        );
439
440        // Only owner's ban should remain — A's ban of B is orphaned and cleaned
441        assert_eq!(
442            result_state.bans.0.len(),
443            1,
444            "Only owner's ban should remain, orphaned ban cleaned: {:?}",
445            result_state.bans.0
446        );
447        assert_eq!(
448            result_state.bans.0[0].banned_by, owner_id,
449            "Remaining ban should be by owner"
450        );
451
452        // Result state should pass verification
453        assert!(
454            result_state.verify(&result_state, &params).is_ok(),
455            "Result state should verify after orphaned ban cleanup: {:?}",
456            result_state.verify(&result_state, &params)
457        );
458    }
459
460    #[test]
461    fn test_member_pruned_when_no_messages() {
462        let rng = &mut rand::thread_rng();
463        let owner_sk = SigningKey::generate(rng);
464        let owner_vk = owner_sk.verifying_key();
465        let owner_id = MemberId::from(&owner_vk);
466        let params = ChatRoomParametersV1 { owner: owner_vk };
467
468        let a_sk = SigningKey::generate(rng);
469        let a_vk = a_sk.verifying_key();
470        let a_id = MemberId::from(&a_vk);
471
472        let b_sk = SigningKey::generate(rng);
473        let b_vk = b_sk.verifying_key();
474
475        let member_a = AuthorizedMember::new(
476            Member {
477                owner_member_id: owner_id,
478                invited_by: owner_id,
479                member_vk: a_vk,
480            },
481            &owner_sk,
482        );
483        let member_b = AuthorizedMember::new(
484            Member {
485                owner_member_id: owner_id,
486                invited_by: owner_id,
487                member_vk: b_vk,
488            },
489            &owner_sk,
490        );
491
492        // Only A has a message
493        let msg_a = AuthorizedMessageV1::new(
494            MessageV1 {
495                room_owner: owner_id,
496                author: a_id,
497                time: SystemTime::now(),
498                content: RoomMessageBody::public("Hello from A".to_string()),
499            },
500            &a_sk,
501        );
502
503        let config = Configuration {
504            max_members: 10,
505            max_recent_messages: 100,
506            ..Default::default()
507        };
508        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
509
510        let mut state = ChatRoomStateV1 {
511            configuration: auth_config,
512            members: MembersV1 {
513                members: vec![member_a, member_b],
514            },
515            recent_messages: MessagesV1 {
516                messages: vec![msg_a],
517                ..Default::default()
518            },
519            ..Default::default()
520        };
521
522        state.post_apply_cleanup(&params).unwrap();
523
524        assert_eq!(state.members.members.len(), 1, "Only A should remain");
525        assert_eq!(state.members.members[0].member.id(), a_id);
526    }
527
528    #[test]
529    fn test_member_with_join_event_not_pruned() {
530        let rng = &mut rand::thread_rng();
531        let owner_sk = SigningKey::generate(rng);
532        let owner_vk = owner_sk.verifying_key();
533        let owner_id = MemberId::from(&owner_vk);
534        let params = ChatRoomParametersV1 { owner: owner_vk };
535
536        let a_sk = SigningKey::generate(rng);
537        let a_vk = a_sk.verifying_key();
538        let a_id = MemberId::from(&a_vk);
539
540        let member_a = AuthorizedMember::new(
541            Member {
542                owner_member_id: owner_id,
543                invited_by: owner_id,
544                member_vk: a_vk,
545            },
546            &owner_sk,
547        );
548
549        // A has only a join event (no regular messages)
550        let join_msg = AuthorizedMessageV1::new(
551            MessageV1 {
552                room_owner: owner_id,
553                author: a_id,
554                time: SystemTime::now(),
555                content: RoomMessageBody::join_event(),
556            },
557            &a_sk,
558        );
559
560        let config = Configuration {
561            max_members: 10,
562            max_recent_messages: 100,
563            ..Default::default()
564        };
565        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
566
567        let mut state = ChatRoomStateV1 {
568            configuration: auth_config,
569            members: MembersV1 {
570                members: vec![member_a],
571            },
572            recent_messages: MessagesV1 {
573                messages: vec![join_msg],
574                ..Default::default()
575            },
576            ..Default::default()
577        };
578
579        state.post_apply_cleanup(&params).unwrap();
580
581        assert_eq!(
582            state.members.members.len(),
583            1,
584            "Member with join event should not be pruned"
585        );
586        assert_eq!(state.members.members[0].member.id(), a_id);
587    }
588
589    /// Test that the atomic join delta (members + member_info + join event)
590    /// as produced by accept_invitation applies correctly and passes verify().
591    #[test]
592    fn test_atomic_join_delta_applies_and_verifies() {
593        use crate::room_state::member::MembersDelta;
594        use crate::room_state::member_info::{AuthorizedMemberInfo, MemberInfo};
595        use crate::room_state::privacy::SealedBytes;
596
597        let rng = &mut rand::thread_rng();
598        let owner_sk = SigningKey::generate(rng);
599        let owner_vk = owner_sk.verifying_key();
600        let owner_id = MemberId::from(&owner_vk);
601        let params = ChatRoomParametersV1 { owner: owner_vk };
602
603        // Create a room with owner config
604        let config = Configuration {
605            owner_member_id: owner_id,
606            max_members: 10,
607            max_recent_messages: 100,
608            ..Default::default()
609        };
610        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
611        let mut state = ChatRoomStateV1 {
612            configuration: auth_config,
613            ..Default::default()
614        };
615
616        // New member accepts an invitation
617        let joiner_sk = SigningKey::generate(rng);
618        let joiner_vk = joiner_sk.verifying_key();
619        let joiner_id = MemberId::from(&joiner_vk);
620
621        let authorized_member = AuthorizedMember::new(
622            Member {
623                owner_member_id: owner_id,
624                invited_by: owner_id,
625                member_vk: joiner_vk,
626            },
627            &owner_sk,
628        );
629
630        let member_info = MemberInfo {
631            member_id: joiner_id,
632            version: 0,
633            preferred_nickname: SealedBytes::public("NewUser".to_string().into_bytes()),
634        };
635        let authorized_info = AuthorizedMemberInfo::new_with_member_key(member_info, &joiner_sk);
636
637        let join_message = AuthorizedMessageV1::new(
638            MessageV1 {
639                room_owner: owner_id,
640                author: joiner_id,
641                content: RoomMessageBody::join_event(),
642                time: SystemTime::now(),
643            },
644            &joiner_sk,
645        );
646
647        // Build the atomic delta (same as accept_invitation produces)
648        let delta = ChatRoomStateV1Delta {
649            recent_messages: Some(vec![join_message]),
650            members: Some(MembersDelta::new(vec![authorized_member])),
651            member_info: Some(vec![authorized_info]),
652            ..Default::default()
653        };
654
655        // Apply delta
656        let old_state = state.clone();
657        state
658            .apply_delta(&old_state, &params, &Some(delta))
659            .expect("atomic join delta should apply cleanly");
660
661        // Verify state is valid
662        state
663            .verify(&state, &params)
664            .expect("state should verify after join delta");
665
666        // Member should be present
667        assert!(
668            state
669                .members
670                .members
671                .iter()
672                .any(|m| m.member.id() == joiner_id),
673            "Joiner should be in members list"
674        );
675
676        // Member info should be present
677        assert!(
678            state
679                .member_info
680                .member_info
681                .iter()
682                .any(|i| i.member_info.member_id == joiner_id),
683            "Joiner should have member_info"
684        );
685
686        // Join event message should be present
687        assert_eq!(state.recent_messages.messages.len(), 1);
688        assert!(state.recent_messages.messages[0].message.content.is_event());
689
690        // Should survive post_apply_cleanup
691        state.post_apply_cleanup(&params).unwrap();
692        assert!(
693            state
694                .members
695                .members
696                .iter()
697                .any(|m| m.member.id() == joiner_id),
698            "Joiner should survive cleanup"
699        );
700    }
701
702    #[test]
703    fn test_invite_chain_preserved_for_active_member() {
704        let rng = &mut rand::thread_rng();
705        let owner_sk = SigningKey::generate(rng);
706        let owner_vk = owner_sk.verifying_key();
707        let owner_id = MemberId::from(&owner_vk);
708        let params = ChatRoomParametersV1 { owner: owner_vk };
709
710        let a_sk = SigningKey::generate(rng);
711        let a_vk = a_sk.verifying_key();
712        let a_id = MemberId::from(&a_vk);
713
714        let b_sk = SigningKey::generate(rng);
715        let b_vk = b_sk.verifying_key();
716        let b_id = MemberId::from(&b_vk);
717
718        // Owner → A → B
719        let member_a = AuthorizedMember::new(
720            Member {
721                owner_member_id: owner_id,
722                invited_by: owner_id,
723                member_vk: a_vk,
724            },
725            &owner_sk,
726        );
727        let member_b = AuthorizedMember::new(
728            Member {
729                owner_member_id: owner_id,
730                invited_by: a_id,
731                member_vk: b_vk,
732            },
733            &a_sk,
734        );
735
736        // Only B has a message
737        let msg_b = AuthorizedMessageV1::new(
738            MessageV1 {
739                room_owner: owner_id,
740                author: b_id,
741                time: SystemTime::now(),
742                content: RoomMessageBody::public("Hello from B".to_string()),
743            },
744            &b_sk,
745        );
746
747        let config = Configuration {
748            max_members: 10,
749            max_recent_messages: 100,
750            ..Default::default()
751        };
752        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
753
754        let mut state = ChatRoomStateV1 {
755            configuration: auth_config,
756            members: MembersV1 {
757                members: vec![member_a, member_b],
758            },
759            recent_messages: MessagesV1 {
760                messages: vec![msg_b],
761                ..Default::default()
762            },
763            ..Default::default()
764        };
765
766        state.post_apply_cleanup(&params).unwrap();
767
768        // Both A and B should remain (A is in B's invite chain)
769        assert_eq!(state.members.members.len(), 2);
770        let member_ids: HashSet<MemberId> = state
771            .members
772            .members
773            .iter()
774            .map(|m| m.member.id())
775            .collect();
776        assert!(
777            member_ids.contains(&a_id),
778            "A should be kept (in B's invite chain)"
779        );
780        assert!(
781            member_ids.contains(&b_id),
782            "B should be kept (has messages)"
783        );
784    }
785
786    #[test]
787    fn test_ban_persists_after_banner_pruned() {
788        let rng = &mut rand::thread_rng();
789        let owner_sk = SigningKey::generate(rng);
790        let owner_vk = owner_sk.verifying_key();
791        let owner_id = MemberId::from(&owner_vk);
792        let params = ChatRoomParametersV1 { owner: owner_vk };
793
794        let a_sk = SigningKey::generate(rng);
795        let a_vk = a_sk.verifying_key();
796        let a_id = MemberId::from(&a_vk);
797
798        let c_sk = SigningKey::generate(rng);
799        let c_vk = c_sk.verifying_key();
800        let c_id = MemberId::from(&c_vk);
801
802        // A is a member (invited by owner)
803        let member_a = AuthorizedMember::new(
804            Member {
805                owner_member_id: owner_id,
806                invited_by: owner_id,
807                member_vk: a_vk,
808            },
809            &owner_sk,
810        );
811
812        // A bans C
813        let ban_c_by_a = AuthorizedUserBan::new(
814            UserBan {
815                owner_member_id: owner_id,
816                banned_at: SystemTime::now(),
817                banned_user: c_id,
818            },
819            a_id,
820            &a_sk,
821        );
822
823        let config = Configuration {
824            max_members: 10,
825            max_user_bans: 10,
826            ..Default::default()
827        };
828        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
829
830        // A has no messages → will be pruned
831        let mut state = ChatRoomStateV1 {
832            configuration: auth_config,
833            members: MembersV1 {
834                members: vec![member_a],
835            },
836            bans: BansV1(vec![ban_c_by_a]),
837            ..Default::default()
838        };
839
840        state.post_apply_cleanup(&params).unwrap();
841
842        // A should be pruned (no messages)
843        assert!(state.members.members.is_empty(), "A should be pruned");
844
845        // A's ban of C should persist (A was pruned, not banned)
846        assert_eq!(state.bans.0.len(), 1, "Ban should persist");
847        assert_eq!(state.bans.0[0].ban.banned_user, c_id);
848        assert_eq!(state.bans.0[0].banned_by, a_id);
849    }
850
851    #[test]
852    fn test_member_re_added_with_message() {
853        let rng = &mut rand::thread_rng();
854        let owner_sk = SigningKey::generate(rng);
855        let owner_vk = owner_sk.verifying_key();
856        let owner_id = MemberId::from(&owner_vk);
857        let params = ChatRoomParametersV1 { owner: owner_vk };
858
859        let a_sk = SigningKey::generate(rng);
860        let a_vk = a_sk.verifying_key();
861        let a_id = MemberId::from(&a_vk);
862
863        let member_a = AuthorizedMember::new(
864            Member {
865                owner_member_id: owner_id,
866                invited_by: owner_id,
867                member_vk: a_vk,
868            },
869            &owner_sk,
870        );
871
872        let config = Configuration {
873            max_members: 10,
874            max_recent_messages: 100,
875            ..Default::default()
876        };
877        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
878
879        // State with A but no messages
880        let mut state = ChatRoomStateV1 {
881            configuration: auth_config,
882            members: MembersV1 {
883                members: vec![member_a.clone()],
884            },
885            ..Default::default()
886        };
887
888        // Cleanup prunes A
889        state.post_apply_cleanup(&params).unwrap();
890        assert!(state.members.members.is_empty(), "A should be pruned");
891
892        // Re-add A with a message
893        state.members.members.push(member_a);
894        let msg = AuthorizedMessageV1::new(
895            MessageV1 {
896                room_owner: owner_id,
897                author: a_id,
898                time: SystemTime::now(),
899                content: RoomMessageBody::public("Hello again!".to_string()),
900            },
901            &a_sk,
902        );
903        state.recent_messages.messages.push(msg);
904
905        // Cleanup should keep A now
906        state.post_apply_cleanup(&params).unwrap();
907        assert_eq!(state.members.members.len(), 1, "A should be kept");
908        assert_eq!(state.members.members[0].member.id(), a_id);
909    }
910
911    #[test]
912    fn test_member_info_cleaned_after_pruning() {
913        let rng = &mut rand::thread_rng();
914        let owner_sk = SigningKey::generate(rng);
915        let owner_vk = owner_sk.verifying_key();
916        let owner_id = MemberId::from(&owner_vk);
917        let params = ChatRoomParametersV1 { owner: owner_vk };
918
919        let a_sk = SigningKey::generate(rng);
920        let a_vk = a_sk.verifying_key();
921        let a_id = MemberId::from(&a_vk);
922
923        let member_a = AuthorizedMember::new(
924            Member {
925                owner_member_id: owner_id,
926                invited_by: owner_id,
927                member_vk: a_vk,
928            },
929            &owner_sk,
930        );
931
932        // Create member_info for A and owner
933        let a_info = AuthorizedMemberInfo::new_with_member_key(
934            MemberInfo::new_public(a_id, 1, "Alice".to_string()),
935            &a_sk,
936        );
937        let owner_info = AuthorizedMemberInfo::new(
938            MemberInfo::new_public(owner_id, 1, "Owner".to_string()),
939            &owner_sk,
940        );
941
942        let config = Configuration {
943            max_members: 10,
944            ..Default::default()
945        };
946        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
947
948        let mut state = ChatRoomStateV1 {
949            configuration: auth_config,
950            members: MembersV1 {
951                members: vec![member_a],
952            },
953            member_info: MemberInfoV1 {
954                member_info: vec![owner_info, a_info],
955            },
956            ..Default::default()
957        };
958
959        // A has no messages → gets pruned along with their member_info
960        state.post_apply_cleanup(&params).unwrap();
961
962        assert!(state.members.members.is_empty(), "A should be pruned");
963        assert_eq!(
964            state.member_info.member_info.len(),
965            1,
966            "Only owner's info should remain"
967        );
968        assert_eq!(
969            state.member_info.member_info[0].member_info.member_id, owner_id,
970            "Remaining info should be owner's"
971        );
972    }
973
974    /// Regression test for issue #110 / Bug #3 PR B:
975    ///
976    /// A member with an `encrypted_secrets` entry (i.e. the owner has
977    /// issued them a per-version room-secret blob) must survive
978    /// `post_apply_cleanup` even if they have not yet authored any
979    /// messages and have no active DMs. The owner-issued blob is proof
980    /// that the owner considers them a member, and pruning them on the
981    /// invitee's first state ingestion is the underlying cause of the
982    /// "DM to inactive member fails" / "newly-invited member silently
983    /// pruned" symptom Ivvor reported in Bug #3.
984    #[test]
985    fn test_member_with_encrypted_secret_survives_cleanup() {
986        let rng = &mut rand::thread_rng();
987        let owner_sk = SigningKey::generate(rng);
988        let owner_vk = owner_sk.verifying_key();
989        let owner_id = MemberId::from(&owner_vk);
990        let params = ChatRoomParametersV1 { owner: owner_vk };
991
992        let a_sk = SigningKey::generate(rng);
993        let a_vk = a_sk.verifying_key();
994        let a_id = MemberId::from(&a_vk);
995
996        let member_a = AuthorizedMember::new(
997            Member {
998                owner_member_id: owner_id,
999                invited_by: owner_id,
1000                member_vk: a_vk,
1001            },
1002            &owner_sk,
1003        );
1004
1005        // A has NO messages and NO DMs — under the pre-fix rules they
1006        // would be pruned by post_apply_cleanup. The owner-issued
1007        // encrypted secret is the only evidence of membership.
1008        let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
1009            member_id: a_id,
1010            secret_version: 0,
1011            ciphertext: vec![0u8; 16], // dummy ciphertext — signature is what counts
1012            nonce: [0u8; 12],
1013            sender_ephemeral_public_key: [0u8; 32],
1014            provider: owner_id,
1015        };
1016        let authorized_secret = crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1017            secret_for_a,
1018            &owner_sk,
1019        );
1020
1021        let config = Configuration {
1022            max_members: 10,
1023            max_recent_messages: 100,
1024            ..Default::default()
1025        };
1026        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1027
1028        let mut state = ChatRoomStateV1 {
1029            configuration: auth_config,
1030            members: MembersV1 {
1031                members: vec![member_a],
1032            },
1033            secrets: crate::room_state::secret::RoomSecretsV1 {
1034                current_version: 0,
1035                versions: vec![],
1036                encrypted_secrets: vec![authorized_secret],
1037            },
1038            ..Default::default()
1039        };
1040
1041        state.post_apply_cleanup(&params).unwrap();
1042
1043        assert_eq!(
1044            state.members.members.len(),
1045            1,
1046            "A should survive cleanup because they have an encrypted_secrets entry"
1047        );
1048        assert_eq!(state.members.members[0].member.id(), a_id);
1049    }
1050
1051    /// IMPORTANT #4 (PR #272 review round 2): a member who is BOTH
1052    /// banned AND has a stale `encrypted_secrets` blob must still be
1053    /// pruned by `post_apply_cleanup`. The exemption introduced for
1054    /// issue #110 grants survival on the strength of the owner's
1055    /// blob, but bans must override — a ban is the owner's later,
1056    /// authoritative statement that this member is no longer trusted.
1057    ///
1058    /// The `members_by_id.contains_key(recipient_id)` guard at the
1059    /// cleanup site keeps this safe: the ban delta runs through the
1060    /// member-prune path before `post_apply_cleanup`'s `required_ids`
1061    /// collection, so by the time we check the exemption set, the
1062    /// banned member is no longer in `members_by_id` and the
1063    /// exemption clause is short-circuited. This test pins that
1064    /// behaviour against any future regression that loosens the
1065    /// guard.
1066    #[test]
1067    fn test_banned_member_with_encrypted_secret_is_still_pruned() {
1068        use crate::room_state::ban::{AuthorizedUserBan, UserBan};
1069        use std::time::SystemTime;
1070
1071        let rng = &mut rand::thread_rng();
1072        let owner_sk = SigningKey::generate(rng);
1073        let owner_vk = owner_sk.verifying_key();
1074        let owner_id = MemberId::from(&owner_vk);
1075        let params = ChatRoomParametersV1 { owner: owner_vk };
1076
1077        let a_sk = SigningKey::generate(rng);
1078        let a_vk = a_sk.verifying_key();
1079        let a_id = MemberId::from(&a_vk);
1080
1081        let member_a = AuthorizedMember::new(
1082            Member {
1083                owner_member_id: owner_id,
1084                invited_by: owner_id,
1085                member_vk: a_vk,
1086            },
1087            &owner_sk,
1088        );
1089
1090        let ban = AuthorizedUserBan::new(
1091            UserBan {
1092                owner_member_id: owner_id,
1093                banned_at: SystemTime::now(),
1094                banned_user: a_id,
1095            },
1096            owner_id,
1097            &owner_sk,
1098        );
1099
1100        // Owner issued a v0 blob for A, then banned A. The blob
1101        // outlives the ban in the state (a peer might receive both
1102        // deltas in one batch). Without proper handling, the
1103        // exemption would resurrect A.
1104        let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
1105            member_id: a_id,
1106            secret_version: 0,
1107            ciphertext: vec![0u8; 16],
1108            nonce: [0u8; 12],
1109            sender_ephemeral_public_key: [0u8; 32],
1110            provider: owner_id,
1111        };
1112        let authorized_secret = crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1113            secret_for_a,
1114            &owner_sk,
1115        );
1116
1117        let config = Configuration {
1118            max_members: 10,
1119            max_recent_messages: 100,
1120            ..Default::default()
1121        };
1122        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1123
1124        let mut state = ChatRoomStateV1 {
1125            configuration: auth_config,
1126            members: MembersV1 {
1127                members: vec![member_a],
1128            },
1129            bans: crate::room_state::ban::BansV1(vec![ban]),
1130            secrets: crate::room_state::secret::RoomSecretsV1 {
1131                current_version: 0,
1132                versions: vec![],
1133                encrypted_secrets: vec![authorized_secret],
1134            },
1135            ..Default::default()
1136        };
1137
1138        // The owner-side flow is: apply ban delta -> members.apply_delta
1139        // removes A from members -> post_apply_cleanup runs. We
1140        // simulate the post-ban-prune state by manually removing A
1141        // from members (matching what `MembersV1::apply_delta` does
1142        // when it sees the ban), then run cleanup.
1143        state.members.members.retain(|m| m.member.id() != a_id);
1144
1145        state.post_apply_cleanup(&params).unwrap();
1146
1147        assert!(
1148            state.members.members.is_empty(),
1149            "banned member A must NOT be resurrected by post_apply_cleanup's \
1150             encrypted_secrets exemption — see IMPORTANT #4 on PR #272 review round 2"
1151        );
1152        // The ban itself must persist.
1153        assert_eq!(state.bans.0.len(), 1);
1154        assert_eq!(state.bans.0[0].ban.banned_user, a_id);
1155    }
1156
1157    /// IMPORTANT #5 (PR #272 review round 2): the
1158    /// `encrypted_secrets` exemption from `post_apply_cleanup` must
1159    /// be SCOPED to the current secret version. A member who has
1160    /// only old-version blobs and hasn't been re-issued at
1161    /// `current_version` is "stale" by the same definition as a
1162    /// member who joined and never authored, and must be pruned.
1163    ///
1164    /// Without this TTL, every ever-recipient + their entire
1165    /// invite-chain ancestor set would be exempt from cleanup
1166    /// forever — defeating the whole point of the inactivity prune.
1167    #[test]
1168    fn test_stale_secret_recipient_is_pruned_after_rotation() {
1169        use crate::room_state::privacy::RoomCipherSpec;
1170        use crate::room_state::secret::{AuthorizedSecretVersionRecord, SecretVersionRecordV1};
1171        use std::time::SystemTime;
1172
1173        let rng = &mut rand::thread_rng();
1174        let owner_sk = SigningKey::generate(rng);
1175        let owner_vk = owner_sk.verifying_key();
1176        let owner_id = MemberId::from(&owner_vk);
1177        let params = ChatRoomParametersV1 { owner: owner_vk };
1178
1179        let a_sk = SigningKey::generate(rng);
1180        let a_vk = a_sk.verifying_key();
1181        let a_id = MemberId::from(&a_vk);
1182
1183        let member_a = AuthorizedMember::new(
1184            Member {
1185                owner_member_id: owner_id,
1186                invited_by: owner_id,
1187                member_vk: a_vk,
1188            },
1189            &owner_sk,
1190        );
1191
1192        // A only has a v0 blob. The room has since rotated to v1
1193        // and A was not re-issued (e.g. they left / were
1194        // implicitly inactive at rotation time).
1195        let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
1196            member_id: a_id,
1197            secret_version: 0,
1198            ciphertext: vec![0u8; 16],
1199            nonce: [0u8; 12],
1200            sender_ephemeral_public_key: [0u8; 32],
1201            provider: owner_id,
1202        };
1203        let authorized_secret_v0 =
1204            crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1205                secret_for_a,
1206                &owner_sk,
1207            );
1208
1209        let v1_record = AuthorizedSecretVersionRecord::new(
1210            SecretVersionRecordV1 {
1211                version: 1,
1212                cipher_spec: RoomCipherSpec::Aes256Gcm,
1213                created_at: SystemTime::now(),
1214            },
1215            &owner_sk,
1216        );
1217
1218        let config = Configuration {
1219            max_members: 10,
1220            max_recent_messages: 100,
1221            ..Default::default()
1222        };
1223        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1224
1225        let mut state = ChatRoomStateV1 {
1226            configuration: auth_config,
1227            members: MembersV1 {
1228                members: vec![member_a],
1229            },
1230            secrets: crate::room_state::secret::RoomSecretsV1 {
1231                current_version: 1,
1232                versions: vec![v1_record],
1233                encrypted_secrets: vec![authorized_secret_v0],
1234            },
1235            ..Default::default()
1236        };
1237
1238        state.post_apply_cleanup(&params).unwrap();
1239
1240        assert!(
1241            state.members.members.is_empty(),
1242            "member A with ONLY a stale v0 blob (no v1 re-issue, no messages, no \
1243             DMs) must be pruned — see IMPORTANT #5 on PR #272 review round 2"
1244        );
1245    }
1246
1247    /// IMPORTANT #6 (PR #272 review round 2): ban-race convergence
1248    /// across peers receiving deltas in different orders. Both
1249    /// orderings — (add-X, ban-X) and (ban-X, add-X) — must
1250    /// converge with X removed, regardless of whether the
1251    /// owner-issued `encrypted_secret` for X arrives before or
1252    /// after the ban.
1253    ///
1254    /// This is the same convergence test pattern PR #240 used for
1255    /// DMs but applied to the new encrypted_secrets exemption.
1256    /// Without this test, a future regression that loosens the
1257    /// "members_by_id.contains_key" guard could leak X back into
1258    /// state via the exemption when the deltas land in the
1259    /// "wrong" order.
1260    #[test]
1261    fn test_ban_race_with_encrypted_secret_converges_to_pruned() {
1262        use crate::room_state::ban::{AuthorizedUserBan, UserBan};
1263        use std::time::SystemTime;
1264
1265        let rng = &mut rand::thread_rng();
1266        let owner_sk = SigningKey::generate(rng);
1267        let owner_vk = owner_sk.verifying_key();
1268        let owner_id = MemberId::from(&owner_vk);
1269        let params = ChatRoomParametersV1 { owner: owner_vk };
1270
1271        let x_sk = SigningKey::generate(rng);
1272        let x_vk = x_sk.verifying_key();
1273        let x_id = MemberId::from(&x_vk);
1274
1275        let member_x = AuthorizedMember::new(
1276            Member {
1277                owner_member_id: owner_id,
1278                invited_by: owner_id,
1279                member_vk: x_vk,
1280            },
1281            &owner_sk,
1282        );
1283
1284        let ban_x = AuthorizedUserBan::new(
1285            UserBan {
1286                owner_member_id: owner_id,
1287                banned_at: SystemTime::now(),
1288                banned_user: x_id,
1289            },
1290            owner_id,
1291            &owner_sk,
1292        );
1293
1294        let secret_for_x = crate::room_state::secret::EncryptedSecretForMemberV1 {
1295            member_id: x_id,
1296            secret_version: 0,
1297            ciphertext: vec![0u8; 16],
1298            nonce: [0u8; 12],
1299            sender_ephemeral_public_key: [0u8; 32],
1300            provider: owner_id,
1301        };
1302        let authorized_secret_x =
1303            crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1304                secret_for_x,
1305                &owner_sk,
1306            );
1307
1308        let config = Configuration {
1309            max_members: 10,
1310            max_recent_messages: 100,
1311            ..Default::default()
1312        };
1313        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1314
1315        // Build the FINAL converged state both peers should arrive
1316        // at: X is banned, X is not in members, the v0
1317        // encrypted_secret for X may or may not be present
1318        // depending on whether peer's secrets state pruned it.
1319        // We simulate the post-merge state where both deltas have
1320        // landed; the in-flight blob for X is still in state when
1321        // post_apply_cleanup runs.
1322        //
1323        // Peer A: applied [add-X@t0, ban-X@t1] — members.apply_delta
1324        // saw the ban and removed X from members. Then the
1325        // secrets delta arrived with a v0 blob for X. Final state:
1326        // members = [], bans = [ban-X], encrypted_secrets = [(x, 0)].
1327        let mut peer_a_state = ChatRoomStateV1 {
1328            configuration: auth_config.clone(),
1329            members: MembersV1 { members: vec![] },
1330            bans: crate::room_state::ban::BansV1(vec![ban_x.clone()]),
1331            secrets: crate::room_state::secret::RoomSecretsV1 {
1332                current_version: 0,
1333                versions: vec![],
1334                encrypted_secrets: vec![authorized_secret_x.clone()],
1335            },
1336            ..Default::default()
1337        };
1338        peer_a_state.post_apply_cleanup(&params).unwrap();
1339        assert!(
1340            peer_a_state.members.members.is_empty(),
1341            "peer A: X must remain pruned despite the encrypted_secret being present"
1342        );
1343
1344        // Peer B: applied [ban-X@t1, add-X@t0]. ban-X was applied
1345        // first; add-X arrived later but was rejected by
1346        // `MembersV1::apply_delta` because X is in the ban list.
1347        // Then the secrets delta arrived with a v0 blob for X.
1348        // Final state matches peer A's.
1349        let mut peer_b_state = ChatRoomStateV1 {
1350            configuration: auth_config,
1351            members: MembersV1 { members: vec![] },
1352            bans: crate::room_state::ban::BansV1(vec![ban_x]),
1353            secrets: crate::room_state::secret::RoomSecretsV1 {
1354                current_version: 0,
1355                versions: vec![],
1356                encrypted_secrets: vec![authorized_secret_x],
1357            },
1358            ..Default::default()
1359        };
1360        peer_b_state.post_apply_cleanup(&params).unwrap();
1361        assert!(
1362            peer_b_state.members.members.is_empty(),
1363            "peer B: X must remain pruned despite the encrypted_secret being present"
1364        );
1365
1366        // The two peers must converge to byte-identical members /
1367        // bans / encrypted_secrets state.
1368        assert_eq!(peer_a_state.members, peer_b_state.members);
1369        assert_eq!(peer_a_state.bans, peer_b_state.bans);
1370        assert_eq!(peer_a_state.secrets, peer_b_state.secrets);
1371
1372        // Suppress unused-variable lints — `member_x` is the seed
1373        // we used to derive `x_id` / `x_vk`; the convergence test
1374        // checks the AFTER-merge state where members is already
1375        // empty by construction.
1376        let _ = member_x;
1377    }
1378
1379    #[test]
1380    fn test_state_with_none_deltas() {
1381        let (state, parameters, owner_signing_key) = create_empty_chat_room_state();
1382
1383        // Create a modified room_state with no changes (all deltas should be None)
1384        let modified_state = state.clone();
1385
1386        // Apply the delta
1387        let summary = state.summarize(&state, &parameters);
1388        let delta = modified_state.delta(&state, &parameters, &summary);
1389
1390        assert!(
1391            delta.is_none(),
1392            "Delta should be None when no changes are made"
1393        );
1394
1395        // Now, let's modify only one field and check if other deltas are None
1396        let mut partially_modified_state = state.clone();
1397        let new_config = Configuration {
1398            configuration_version: 2,
1399            ..partially_modified_state.configuration.configuration.clone()
1400        };
1401        partially_modified_state.configuration =
1402            AuthorizedConfigurationV1::new(new_config, &owner_signing_key);
1403
1404        let summary = state.summarize(&state, &parameters);
1405        let delta = partially_modified_state
1406            .delta(&state, &parameters, &summary)
1407            .unwrap();
1408
1409        // Check that only the configuration delta is Some, and others are None
1410        assert!(
1411            delta.configuration.is_some(),
1412            "Configuration delta should be Some"
1413        );
1414        assert!(delta.bans.is_none(), "Bans delta should be None");
1415        assert!(delta.members.is_none(), "Members delta should be None");
1416        assert!(
1417            delta.member_info.is_none(),
1418            "Member info delta should be None"
1419        );
1420        assert!(
1421            delta.recent_messages.is_none(),
1422            "Recent messages delta should be None"
1423        );
1424        assert!(delta.upgrade.is_none(), "Upgrade delta should be None");
1425
1426        // Apply the partial delta
1427        let mut new_state = state.clone();
1428        new_state
1429            .apply_delta(&state, &parameters, &Some(delta))
1430            .unwrap();
1431
1432        assert_eq!(
1433            new_state, partially_modified_state,
1434            "State should be partially modified"
1435        );
1436    }
1437}