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    /// IDEMPOTENCE / CONVERGENCE INVARIANT: this function MUST be idempotent
88    /// (`cleanup(S) == cleanup(cleanup(S))`) and a pure function of the converged
89    /// state, because Freenet runs it a variable number of times across peers
90    /// (and full-state PUTs bypass it via `verify`). The `max_user_bans` cap is
91    /// therefore applied at the TOP (step 0-cap) so ban enforcement and the
92    /// banner-prune exemption read the FINAL surviving ban set — see the block
93    /// comments below and #411 round 7 / Codex P1 #1+#2.
94    ///
95    /// Direct-message sweep: after pruning, any DM whose sender or
96    /// recipient is now non-member or banned is dropped. Without this,
97    /// adding a ban for a DM participant would silently make every
98    /// peer's verify fail, and members referenced only by a DM would be
99    /// pruned (orphaning their DMs). See
100    /// `direct_messages.rs` module docs, "Interaction with bans".
101    pub fn post_apply_cleanup(&mut self, parameters: &ChatRoomParametersV1) -> Result<(), String> {
102        let owner_id = MemberId::from(&parameters.owner);
103
104        // 0-cap. Enforce `max_user_bans` FIRST — BEFORE ban enforcement (step 0)
105        //     and the banner inactivity-prune exemption (step 2) — so both read
106        //     the FINAL surviving (post-cap) ban set (#411 round 7 / Codex P1
107        //     #1+#2). Running the cap here, on the converged pre-enforcement
108        //     state, is what keeps `post_apply_cleanup` IDEMPOTENT and identical
109        //     across peers:
110        //       * #1: an over-cap ban that WILL be evicted must not one-shot a
111        //         member removal at step 0 that the capped converged state
112        //         cannot reproduce — a peer that only ever sees the capped state
113        //         (e.g. via a full-state PUT that bypasses cleanup) would keep
114        //         that member, so removing it here would diverge the member set.
115        //       * #2: a banner whose ban the cap evicts must NOT be exempted
116        //         from inactivity-prune at step 2. If it were (as when the cap
117        //         ran last), the banner is kept on pass 1 but its ban is then
118        //         evicted, so pass 2 prunes it — `cleanup(S) != cleanup(cleanup(S))`,
119        //         which permanently diverges peers that run cleanup a different
120        //         number of times.
121        //     Eviction drops INERT (currently-unauthorized) bans before
122        //     enforcing ones (#410 review round 1). This bounds an INERT flood
123        //     (forged / revoked-deputy bans, which `verify` accepts) — those are
124        //     evicted first, so a flood of them cannot push real moderator bans
125        //     out of the cap. It does NOT fully defend the un-ban DoS: an
126        //     ENFORCING-absent-target flood still evicts real bans, because a ban
127        //     by a current member of an ABSENT target classifies "enforcing"
128        //     WITHOUT any authorization check (`ban_is_enforcing` returns true for
129        //     a member-banner + absent target), and `banned_at` is an
130        //     attacker-signed, future-datable field — so a member can mint many
131        //     newest-dated "enforcing" bans that outrank and evict genuine ones.
132        //     The substantive fix (an authorization-aware / non-attacker-ordered
133        //     cap) is deferred pending Ian's decision; tracked in
134        //     freenet/river#413 (Limitation 2).
135        //
136        //     CONVERGENCE HONESTY: the enforcing/inert classification is a
137        //     deterministic function of the member set it runs against, but that
138        //     member set is the INTERMEDIATE (pre-step-0) set as `apply_delta`
139        //     left it, which is order-dependent (cascade removal is arrival-order
140        //     sensitive). So two peers applying the same delta multiset in a
141        //     different order can evict DIFFERENT bans and NOT be byte-equal
142        //     without a further exchange; anti-entropy `merge` reconciles them to
143        //     the same top-set (River's ban summary is a full BanId set, so
144        //     anti-entropy always converges — pinned by
145        //     `cap_eviction_reconciles_via_merge`; tracked in freenet/river#413,
146        //     Limitation 1). Do NOT claim the eviction is order-independent per
147        //     delta.
148        //     `verify`'s hard cap ceiling still rejects any stored state left over
149        //     the cap. The signature sweep (step 5) still runs AFTER enforcement
150        //     to drop bans orphaned by a banner's removal, and only removes bans,
151        //     so the ban count stays <= the cap.
152        let max_bans = self.configuration.configuration.max_user_bans;
153        if self.bans.0.len() > max_bans {
154            let members_by_id = self.members.members_by_member_id();
155            // Order so the entries to DROP come first: inert-before-enforcing,
156            // then oldest-before-newest, then ban id (fully deterministic).
157            // `sort_by_cached_key` computes `ban_is_enforcing` at most ONCE per
158            // ban (not O(n log n) times inside a comparator) — #411 round 3 C.
159            self.bans.0.sort_by_cached_key(|ban| {
160                (
161                    BansV1::ban_is_enforcing(
162                        ban,
163                        &members_by_id,
164                        &self.member_info,
165                        owner_id,
166                        &parameters.owner,
167                    ),
168                    ban.ban.banned_at,
169                    ban.id(),
170                )
171            });
172            let to_remove = self.bans.0.len() - max_bans;
173            self.bans.0.drain(0..to_remove);
174            // Restore the canonical (banned_at, id) stored order.
175            self.bans.0.sort_by(|a, b| {
176                a.ban
177                    .banned_at
178                    .cmp(&b.ban.banned_at)
179                    .then_with(|| a.id().cmp(&b.id()))
180            });
181        }
182
183        // 0. Enforce bans from the CONVERGED (now capped) state, deputy-aware (#410).
184        //
185        // `MembersV1::apply_delta` already removed members banned by the owner
186        // or an ancestor, but it ran BEFORE the sibling `member_info` field
187        // (which carries deputy grants) was applied, so it could not evaluate
188        // deputy authority. This pass runs after every field has been applied,
189        // so `self.member_info` is converged: it removes members banned by a
190        // currently-authorized deputy, and — crucially — does NOT remove
191        // members whose deputy was revoked (the deputizer removed them from
192        // `MemberInfo.deputies` at a higher version). Because the removal set
193        // is a pure function of the converged (members + deputies + bans)
194        // state, and bans stay an add-only CRDT (never pruned here), every peer
195        // converges to the same member set regardless of delta order. Kept in
196        // post_apply_cleanup (NOT verify) so verify stays stable across
197        // ban/deputy changes — mirrors the DM ban-sweep precedent.
198        let enforced_banned_ids =
199            self.members
200                .banned_member_ids(&self.bans, &self.member_info, parameters);
201        self.members
202            .members
203            .retain(|m| !enforced_banned_ids.contains(&m.member.id()));
204
205        // 1. Collect message author IDs + DM participants + secret recipients.
206        //
207        // Secret recipients (i.e. members for whom the owner has issued an
208        // `encrypted_secrets` blob AT THE CURRENT VERSION) are exempt
209        // from inactivity-prune. The owner explicitly chose to issue
210        // them a per-version room secret, so the owner clearly considers
211        // them a member — and post_apply cleanup running on an
212        // invitee's first state ingestion (which arrives before the
213        // invitee has authored any join_event) must not silently delete
214        // that membership. See issue #110 / Bug #3 PR B (Ivvor
215        // 2026-05-17).
216        //
217        // The exemption is restricted to recipients at `current_version`
218        // so cleanup still prunes genuinely-inactive members whose
219        // blobs are only present at older versions (a member who joined,
220        // received v0, never authored anything, and was never re-issued
221        // a blob at v1+ is "stale" by the same definition as a member
222        // who joined and never authored). Without this scoping the
223        // exemption would keep every ever-recipient + their entire
224        // invite chain ancestor set exempt from cleanup forever,
225        // defeating the prune. See IMPORTANT item #5 on PR #272
226        // review round 2.
227        let message_authors: HashSet<MemberId> = self
228            .recent_messages
229            .messages
230            .iter()
231            .map(|m| m.message.author)
232            .collect();
233        let dm_participants: HashSet<MemberId> = self.direct_messages.active_participants();
234        let current_secret_version = self.secrets.current_version;
235        let secret_recipients: HashSet<MemberId> = self
236            .secrets
237            .encrypted_secrets
238            .iter()
239            .filter(|s| s.secret.secret_version == current_secret_version)
240            .map(|s| s.secret.member_id)
241            .collect();
242
243        // 2. Compute required members: authors + DM participants + secret
244        //    recipients + their invite chains.
245        let required_ids = {
246            let members_by_id = self.members.members_by_member_id();
247            let mut required_ids: HashSet<MemberId> = HashSet::new();
248
249            for author_id in &message_authors {
250                if *author_id != owner_id && members_by_id.contains_key(author_id) {
251                    required_ids.insert(*author_id);
252                }
253            }
254
255            for participant_id in &dm_participants {
256                if *participant_id != owner_id && members_by_id.contains_key(participant_id) {
257                    required_ids.insert(*participant_id);
258                }
259            }
260
261            for recipient_id in &secret_recipients {
262                if *recipient_id != owner_id && members_by_id.contains_key(recipient_id) {
263                    required_ids.insert(*recipient_id);
264                }
265            }
266
267            // A member who is the BANNER of a ban that will SURVIVE the step-5
268            // sweep is exempt from inactivity-prune (#411 round 3 item B).
269            // Otherwise an inactive moderator's bans would vanish (a banner pruned
270            // to non-member has their bans swept in step 5). Mirrors the
271            // `encrypted_secrets` exemption and is a pure function of converged
272            // state. `self.bans.0` was ALREADY capped to `max_user_bans` at step
273            // 0-cap (top of this function), so this loop iterates only the
274            // surviving bans: a banner whose ban the cap evicted is NOT exempted
275            // here, so it is not kept on pass 1 and then pruned on pass 2 (#411
276            // round 7 / Codex P1 #2 — the cap MUST precede this exemption).
277            // Runs BEFORE the invite-chain walk so the banner's ancestors are
278            // kept too (a kept member needs a valid chain). The owner can still
279            // explicitly ban an abusive banner — banning is separate from
280            // inactivity-prune, and a banned banner's bans are then swept in step 5.
281            //
282            // IDEMPOTENCE (#411 round 4/5): the exemption MUST use the SAME
283            // predicate as the step-5 sweep — `ban_signature_matches_current_key`,
284            // NOT a bare `contains_key`. Round 4 made the sweep drop a
285            // current-member-banner ban whose signature FAILS; if the exemption
286            // still kept the banner on a bare membership check, a content-free
287            // member P held solely by a garbage-sig ban Z would be KEPT on pass 1
288            // (exempted) while Z is swept, then PRUNED on pass 2 (no ban left) —
289            // so `cleanup(S) != cleanup(cleanup(S))`. Because Freenet runs
290            // post_apply_cleanup a variable number of times (and full-state PUTs
291            // bypass it via verify), that non-idempotence diverges the member set
292            // permanently. Gating on the sweep predicate makes exemption ⟺
293            // retention: a banner is exempted iff its ban actually survives. No
294            // circularity — a sig-matching banner is added to `required_ids`, so it
295            // survives step 3 to step 5, where the same predicate keeps its ban.
296            for ban in &self.bans.0 {
297                let banner = ban.banned_by;
298                if banner != owner_id
299                    && BansV1::ban_signature_matches_current_key(
300                        ban,
301                        &members_by_id,
302                        owner_id,
303                        &parameters.owner,
304                    )
305                {
306                    required_ids.insert(banner);
307                }
308            }
309
310            // Walk invite chains upward, adding all ancestors (stop at owner)
311            let mut to_process: Vec<MemberId> = required_ids.iter().cloned().collect();
312            while let Some(member_id) = to_process.pop() {
313                if let Some(member) = members_by_id.get(&member_id) {
314                    let inviter_id = member.member.invited_by;
315                    if inviter_id != owner_id && !required_ids.contains(&inviter_id) {
316                        required_ids.insert(inviter_id);
317                        to_process.push(inviter_id);
318                    }
319                }
320            }
321
322            required_ids
323        };
324
325        // 3. Prune members not in required set
326        self.members
327            .members
328            .retain(|m| required_ids.contains(&m.member.id()));
329
330        // 4. Clean member_info for pruned members
331        self.member_info.member_info.retain(|info| {
332            info.member_info.member_id == owner_id
333                || required_ids.contains(&info.member_info.member_id)
334        });
335
336        // 4a. Collapse duplicate member_info records to the single canonical
337        //     (highest-rank) one per member (#411 round 8 item C / security
338        //     FINDING 2+3). `verify` accepts duplicate records (migration-safety),
339        //     so a peer can hold several records for one member; without this,
340        //     two peers with different duplicate SETS would diverge byte-for-byte
341        //     forever (the raw `member_info` vectors differ even though every
342        //     canonical read agrees). Dedup is a pure function of the converged
343        //     state, so it is deterministic, idempotent, and order-independent,
344        //     and bounds stored `member_info` to <= one record per member. Runs
345        //     here (post_apply_cleanup), never in verify/validate_state, so the
346        //     permissionless migration PUT is unaffected.
347        self.member_info.dedup_to_canonical();
348
349        // 4b. Sweep recent messages authored by members removed above (deputy-
350        //     authorized ban cascade or inactivity prune).
351        //     `MessagesV1::apply_delta` already drops non-member-authored
352        //     messages, but it runs BEFORE this cleanup in field order — so a
353        //     member removed HERE (a deputy-authorized ban, #410, is only
354        //     enforceable once the converged member_info is available, which is
355        //     after the recent_messages field has been applied) would otherwise
356        //     leave orphaned messages that fail `MessagesV1::verify`
357        //     ("Message author not found"). Owner-authored messages are always
358        //     valid.
359        let current_member_ids: HashSet<MemberId> =
360            self.members.members.iter().map(|m| m.member.id()).collect();
361        self.recent_messages.messages.retain(|m| {
362            m.message.author == owner_id || current_member_ids.contains(&m.message.author)
363        });
364
365        // Rebuild the PUBLIC `actions_state` cache now that removed authors'
366        // messages are gone (#411 round 7 / Codex P2 #4). `MessagesV1::apply_delta`
367        // already rebuilt this cache, but it ran BEFORE the sweep above, so a
368        // deputy-banned member's edit/delete/reaction would linger in the cache
369        // (e.g. their reaction still rendered on a message). The UI's private
370        // rebuild (`rebuild_actions_state_with_decrypted`) is a no-op for a public
371        // room, so nothing else recomputes it. This is the same public-only rebuild
372        // `apply_delta` runs; the UI re-runs its private rebuild after apply.
373        self.recent_messages.rebuild_actions_state();
374
375        // 5. Sweep any ban that is not backed by a signature-verified authority
376        //    (#411 round 3 item A.3 + round 4 item A). Nothing unvalidated stays
377        //    in state (AGENTS.md State Authorization Rule). A ban is kept only if
378        //    `ban_signature_matches_current_key` holds: the banner is the OWNER or
379        //    a CURRENT member AND the stored signature verifies against that
380        //    banner's CURRENT converged key. This drops two classes:
381        //    (a) non-member banners — a stale/pruned deputy ID or forged banner,
382        //        whose signature `verify` skipped and whom `is_ban_authorized`
383        //        grants nothing (round 3); and
384        //    (b) current-member banners whose signature does NOT match the
385        //        converged key — the same-delta pruned-deputy REPLAY forgery,
386        //        where a public `AuthorizedMember` was replayed to make the banner
387        //        a member while `verify` skipped the garbage ban signature at
388        //        apply time (round 4). Enforcement (step 0 / `banned_member_ids`)
389        //        already refuses to act on such a ban; this sweeps it from state.
390        //    Real member-banners with valid signatures were kept present by the
391        //    item-B exemption above, so their bans survive. Runs against CONVERGED
392        //    state, keeping `verify` stable (migration-safe). `members_by_id` is
393        //    rebuilt here because the sweep needs each banner's `member_vk`.
394        let members_by_id_for_ban_sweep = self.members.members_by_member_id();
395        self.bans.0.retain(|ban| {
396            BansV1::ban_signature_matches_current_key(
397                ban,
398                &members_by_id_for_ban_sweep,
399                owner_id,
400                &parameters.owner,
401            )
402        });
403
404        // (The `max_user_bans` cap runs at the TOP of this function now — step
405        // "0-cap" — so ban enforcement and the banner exemption read the final
406        // surviving ban set. This signature sweep only shrinks the set further,
407        // so the count stays <= the cap. See #411 round 7 / Codex P1 #1+#2.)
408
409        // 6. Sweep DMs whose participants are no longer current members
410        //    or are ENFORCED-banned. Without this, a fresh ban (or member-prune)
411        //    would leave the DMs in state but break `verify` because the
412        //    sender/recipient can no longer be resolved.
413        //
414        //    We use the enforced-ban set from step 0 rather than every ban
415        //    target: a member whose ban is inert (e.g. a revoked deputy's ban,
416        //    #410) is still a current member and their DMs must survive.
417        //    Enforced-banned members were already removed above, so the
418        //    active-member check alone would sweep them, but passing the set is
419        //    harmless and keeps the intent explicit.
420        let active_member_ids_for_sweep: HashSet<MemberId> =
421            self.members.members.iter().map(|m| m.member.id()).collect();
422        self.direct_messages.sweep_after_membership_change(
423            owner_id,
424            &active_member_ids_for_sweep,
425            &enforced_banned_ids,
426        );
427
428        // 7. Re-sort for deterministic ordering
429        self.members.members.sort_by_key(|m| m.member.id());
430        self.member_info
431            .member_info
432            .sort_by_key(|info| info.member_info.member_id);
433
434        Ok(())
435    }
436}
437
438#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Debug)]
439pub struct ChatRoomParametersV1 {
440    pub owner: VerifyingKey,
441}
442
443impl ChatRoomParametersV1 {
444    pub fn owner_id(&self) -> MemberId {
445        self.owner.into()
446    }
447}
448
449#[cfg(test)]
450mod tests {
451    use super::*;
452    use crate::room_state::ban::{AuthorizedUserBan, UserBan};
453    use crate::room_state::configuration::Configuration;
454    use crate::room_state::member::{AuthorizedMember, Member};
455    use crate::room_state::member_info::{AuthorizedMemberInfo, MemberInfo};
456    use crate::room_state::message::{AuthorizedMessageV1, MessageV1, RoomMessageBody};
457    use ed25519_dalek::SigningKey;
458    use std::fmt::Debug;
459    use std::time::SystemTime;
460
461    #[test]
462    fn test_state() {
463        let (state, parameters, owner_signing_key) = create_empty_chat_room_state();
464
465        assert!(
466            state.verify(&state, &parameters).is_ok(),
467            "Empty state should verify"
468        );
469
470        // Test that the configuration can be updated
471        let mut new_cfg = state.configuration.configuration.clone();
472        new_cfg.configuration_version += 1;
473        new_cfg.max_recent_messages = 10; // Change from default of 100 to 10
474        let new_cfg = AuthorizedConfigurationV1::new(new_cfg, &owner_signing_key);
475
476        let mut cfg_modified_state = state.clone();
477        cfg_modified_state.configuration = new_cfg;
478        test_apply_delta(state.clone(), cfg_modified_state, &parameters);
479    }
480
481    fn test_apply_delta<CS>(orig_state: CS, modified_state: CS, parameters: &CS::Parameters)
482    where
483        CS: ComposableState<ParentState = CS> + Clone + PartialEq + Debug,
484    {
485        let orig_verify_result = orig_state.verify(&orig_state, parameters);
486        assert!(
487            orig_verify_result.is_ok(),
488            "Original state verification failed: {:?}",
489            orig_verify_result.err()
490        );
491
492        let modified_verify_result = modified_state.verify(&modified_state, parameters);
493        assert!(
494            modified_verify_result.is_ok(),
495            "Modified state verification failed: {:?}",
496            modified_verify_result.err()
497        );
498
499        let delta = modified_state.delta(
500            &orig_state,
501            parameters,
502            &orig_state.summarize(&orig_state, parameters),
503        );
504
505        println!("Delta: {:?}", delta);
506
507        let mut new_state = orig_state.clone();
508        let apply_delta_result = new_state.apply_delta(&orig_state, parameters, &delta);
509        assert!(
510            apply_delta_result.is_ok(),
511            "Applying delta failed: {:?}",
512            apply_delta_result.err()
513        );
514
515        assert_eq!(new_state, modified_state);
516    }
517    fn create_empty_chat_room_state() -> (ChatRoomStateV1, ChatRoomParametersV1, SigningKey) {
518        // Create a test room_state with a single member and two messages, one written by
519        // the owner and one by the member - the member must be invited by the owner
520        let rng = &mut rand::thread_rng();
521        let owner_signing_key = SigningKey::generate(rng);
522        let owner_verifying_key = owner_signing_key.verifying_key();
523
524        let config = AuthorizedConfigurationV1::new(Configuration::default(), &owner_signing_key);
525
526        (
527            ChatRoomStateV1 {
528                configuration: config,
529                bans: BansV1::default(),
530                members: MembersV1::default(),
531                member_info: MemberInfoV1::default(),
532                secrets: RoomSecretsV1::default(),
533                recent_messages: MessagesV1::default(),
534                upgrade: OptionalUpgradeV1(None),
535                ..Default::default()
536            },
537            ChatRoomParametersV1 {
538                owner: owner_verifying_key,
539            },
540            owner_signing_key,
541        )
542    }
543
544    /// Regression test: when a member who issued bans is subsequently banned themselves,
545    /// their bans become orphaned (banning member no longer in members list and not owner).
546    /// The post_apply_delta hook post_apply_cleanup must remove these to prevent verify() failure.
547    /// See: technic corrupted state incident (Feb 2026)
548    #[test]
549    fn test_orphaned_ban_cleanup_after_cascade_removal() {
550        let rng = &mut rand::thread_rng();
551
552        // Create owner
553        let owner_sk = SigningKey::generate(rng);
554        let owner_vk = owner_sk.verifying_key();
555        let owner_id = MemberId::from(&owner_vk);
556        let params = ChatRoomParametersV1 { owner: owner_vk };
557
558        // Configuration allowing bans and members
559        let config = Configuration {
560            max_user_bans: 10,
561            max_members: 10,
562            ..Default::default()
563        };
564        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
565
566        // Create member A (invited by owner) and member B (invited by A)
567        let a_sk = SigningKey::generate(rng);
568        let a_vk = a_sk.verifying_key();
569        let a_id = MemberId::from(&a_vk);
570
571        let b_sk = SigningKey::generate(rng);
572        let b_vk = b_sk.verifying_key();
573        let b_id = MemberId::from(&b_vk);
574
575        let member_a = AuthorizedMember::new(
576            Member {
577                owner_member_id: owner_id,
578                invited_by: owner_id,
579                member_vk: a_vk,
580            },
581            &owner_sk,
582        );
583
584        // A bans B (authorized because A is in B's invite chain)
585        let ban_b_by_a = AuthorizedUserBan::new(
586            UserBan {
587                owner_member_id: owner_id,
588                banned_at: std::time::SystemTime::now(),
589                banned_user: b_id,
590            },
591            a_id,
592            &a_sk,
593        );
594
595        // Initial state: A is a member, B already removed (ban took effect)
596        let initial_state = ChatRoomStateV1 {
597            configuration: auth_config.clone(),
598            bans: BansV1(vec![ban_b_by_a.clone()]),
599            members: MembersV1 {
600                members: vec![member_a.clone()],
601            },
602            ..Default::default()
603        };
604
605        assert!(
606            initial_state.verify(&initial_state, &params).is_ok(),
607            "Initial state should verify: {:?}",
608            initial_state.verify(&initial_state, &params)
609        );
610
611        // Now owner bans A — this will cascade-remove A from members,
612        // making A's ban of B orphaned (A is no longer in members and not owner)
613        let ban_a_by_owner = AuthorizedUserBan::new(
614            UserBan {
615                owner_member_id: owner_id,
616                banned_at: std::time::SystemTime::now() + std::time::Duration::from_secs(1),
617                banned_user: a_id,
618            },
619            owner_id,
620            &owner_sk,
621        );
622
623        // Modified state for delta computation: add owner's ban of A
624        let modified_for_delta = ChatRoomStateV1 {
625            configuration: auth_config,
626            bans: BansV1(vec![ban_b_by_a.clone(), ban_a_by_owner.clone()]),
627            members: MembersV1 {
628                members: vec![member_a.clone()],
629            },
630            ..Default::default()
631        };
632
633        // Compute and apply delta
634        let summary = initial_state.summarize(&initial_state, &params);
635        let delta = modified_for_delta.delta(&initial_state, &params, &summary);
636
637        let mut result_state = initial_state.clone();
638        let apply_result = result_state.apply_delta(&initial_state, &params, &delta);
639        assert!(
640            apply_result.is_ok(),
641            "apply_delta should succeed: {:?}",
642            apply_result
643        );
644
645        // A should be removed (banned by owner)
646        assert!(
647            result_state.members.members.is_empty(),
648            "A should be removed from members: {:?}",
649            result_state.members.members
650        );
651
652        // Only owner's ban should remain — A's ban of B is orphaned and cleaned
653        assert_eq!(
654            result_state.bans.0.len(),
655            1,
656            "Only owner's ban should remain, orphaned ban cleaned: {:?}",
657            result_state.bans.0
658        );
659        assert_eq!(
660            result_state.bans.0[0].banned_by, owner_id,
661            "Remaining ban should be by owner"
662        );
663
664        // Result state should pass verification
665        assert!(
666            result_state.verify(&result_state, &params).is_ok(),
667            "Result state should verify after orphaned ban cleanup: {:?}",
668            result_state.verify(&result_state, &params)
669        );
670    }
671
672    #[test]
673    fn test_member_pruned_when_no_messages() {
674        let rng = &mut rand::thread_rng();
675        let owner_sk = SigningKey::generate(rng);
676        let owner_vk = owner_sk.verifying_key();
677        let owner_id = MemberId::from(&owner_vk);
678        let params = ChatRoomParametersV1 { owner: owner_vk };
679
680        let a_sk = SigningKey::generate(rng);
681        let a_vk = a_sk.verifying_key();
682        let a_id = MemberId::from(&a_vk);
683
684        let b_sk = SigningKey::generate(rng);
685        let b_vk = b_sk.verifying_key();
686
687        let member_a = AuthorizedMember::new(
688            Member {
689                owner_member_id: owner_id,
690                invited_by: owner_id,
691                member_vk: a_vk,
692            },
693            &owner_sk,
694        );
695        let member_b = AuthorizedMember::new(
696            Member {
697                owner_member_id: owner_id,
698                invited_by: owner_id,
699                member_vk: b_vk,
700            },
701            &owner_sk,
702        );
703
704        // Only A has a message
705        let msg_a = AuthorizedMessageV1::new(
706            MessageV1 {
707                room_owner: owner_id,
708                author: a_id,
709                time: SystemTime::now(),
710                content: RoomMessageBody::public("Hello from A".to_string()),
711            },
712            &a_sk,
713        );
714
715        let config = Configuration {
716            max_members: 10,
717            max_recent_messages: 100,
718            ..Default::default()
719        };
720        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
721
722        let mut state = ChatRoomStateV1 {
723            configuration: auth_config,
724            members: MembersV1 {
725                members: vec![member_a, member_b],
726            },
727            recent_messages: MessagesV1 {
728                messages: vec![msg_a],
729                ..Default::default()
730            },
731            ..Default::default()
732        };
733
734        state.post_apply_cleanup(&params).unwrap();
735
736        assert_eq!(state.members.members.len(), 1, "Only A should remain");
737        assert_eq!(state.members.members[0].member.id(), a_id);
738    }
739
740    #[test]
741    fn test_member_with_join_event_not_pruned() {
742        let rng = &mut rand::thread_rng();
743        let owner_sk = SigningKey::generate(rng);
744        let owner_vk = owner_sk.verifying_key();
745        let owner_id = MemberId::from(&owner_vk);
746        let params = ChatRoomParametersV1 { owner: owner_vk };
747
748        let a_sk = SigningKey::generate(rng);
749        let a_vk = a_sk.verifying_key();
750        let a_id = MemberId::from(&a_vk);
751
752        let member_a = AuthorizedMember::new(
753            Member {
754                owner_member_id: owner_id,
755                invited_by: owner_id,
756                member_vk: a_vk,
757            },
758            &owner_sk,
759        );
760
761        // A has only a join event (no regular messages)
762        let join_msg = AuthorizedMessageV1::new(
763            MessageV1 {
764                room_owner: owner_id,
765                author: a_id,
766                time: SystemTime::now(),
767                content: RoomMessageBody::join_event(),
768            },
769            &a_sk,
770        );
771
772        let config = Configuration {
773            max_members: 10,
774            max_recent_messages: 100,
775            ..Default::default()
776        };
777        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
778
779        let mut state = ChatRoomStateV1 {
780            configuration: auth_config,
781            members: MembersV1 {
782                members: vec![member_a],
783            },
784            recent_messages: MessagesV1 {
785                messages: vec![join_msg],
786                ..Default::default()
787            },
788            ..Default::default()
789        };
790
791        state.post_apply_cleanup(&params).unwrap();
792
793        assert_eq!(
794            state.members.members.len(),
795            1,
796            "Member with join event should not be pruned"
797        );
798        assert_eq!(state.members.members[0].member.id(), a_id);
799    }
800
801    /// Test that the atomic join delta (members + member_info + join event)
802    /// as produced by accept_invitation applies correctly and passes verify().
803    #[test]
804    fn test_atomic_join_delta_applies_and_verifies() {
805        use crate::room_state::member::MembersDelta;
806        use crate::room_state::member_info::{AuthorizedMemberInfo, MemberInfo};
807        use crate::room_state::privacy::SealedBytes;
808
809        let rng = &mut rand::thread_rng();
810        let owner_sk = SigningKey::generate(rng);
811        let owner_vk = owner_sk.verifying_key();
812        let owner_id = MemberId::from(&owner_vk);
813        let params = ChatRoomParametersV1 { owner: owner_vk };
814
815        // Create a room with owner config
816        let config = Configuration {
817            owner_member_id: owner_id,
818            max_members: 10,
819            max_recent_messages: 100,
820            ..Default::default()
821        };
822        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
823        let mut state = ChatRoomStateV1 {
824            configuration: auth_config,
825            ..Default::default()
826        };
827
828        // New member accepts an invitation
829        let joiner_sk = SigningKey::generate(rng);
830        let joiner_vk = joiner_sk.verifying_key();
831        let joiner_id = MemberId::from(&joiner_vk);
832
833        let authorized_member = AuthorizedMember::new(
834            Member {
835                owner_member_id: owner_id,
836                invited_by: owner_id,
837                member_vk: joiner_vk,
838            },
839            &owner_sk,
840        );
841
842        let member_info = MemberInfo {
843            member_id: joiner_id,
844            version: 0,
845            preferred_nickname: SealedBytes::public("NewUser".to_string().into_bytes()),
846            deputies: Vec::new(),
847        };
848        let authorized_info = AuthorizedMemberInfo::new_with_member_key(member_info, &joiner_sk);
849
850        let join_message = AuthorizedMessageV1::new(
851            MessageV1 {
852                room_owner: owner_id,
853                author: joiner_id,
854                content: RoomMessageBody::join_event(),
855                time: SystemTime::now(),
856            },
857            &joiner_sk,
858        );
859
860        // Build the atomic delta (same as accept_invitation produces)
861        let delta = ChatRoomStateV1Delta {
862            recent_messages: Some(vec![join_message]),
863            members: Some(MembersDelta::new(vec![authorized_member])),
864            member_info: Some(vec![authorized_info]),
865            ..Default::default()
866        };
867
868        // Apply delta
869        let old_state = state.clone();
870        state
871            .apply_delta(&old_state, &params, &Some(delta))
872            .expect("atomic join delta should apply cleanly");
873
874        // Verify state is valid
875        state
876            .verify(&state, &params)
877            .expect("state should verify after join delta");
878
879        // Member should be present
880        assert!(
881            state
882                .members
883                .members
884                .iter()
885                .any(|m| m.member.id() == joiner_id),
886            "Joiner should be in members list"
887        );
888
889        // Member info should be present
890        assert!(
891            state
892                .member_info
893                .member_info
894                .iter()
895                .any(|i| i.member_info.member_id == joiner_id),
896            "Joiner should have member_info"
897        );
898
899        // Join event message should be present
900        assert_eq!(state.recent_messages.messages.len(), 1);
901        assert!(state.recent_messages.messages[0].message.content.is_event());
902
903        // Should survive post_apply_cleanup
904        state.post_apply_cleanup(&params).unwrap();
905        assert!(
906            state
907                .members
908                .members
909                .iter()
910                .any(|m| m.member.id() == joiner_id),
911            "Joiner should survive cleanup"
912        );
913    }
914
915    #[test]
916    fn test_invite_chain_preserved_for_active_member() {
917        let rng = &mut rand::thread_rng();
918        let owner_sk = SigningKey::generate(rng);
919        let owner_vk = owner_sk.verifying_key();
920        let owner_id = MemberId::from(&owner_vk);
921        let params = ChatRoomParametersV1 { owner: owner_vk };
922
923        let a_sk = SigningKey::generate(rng);
924        let a_vk = a_sk.verifying_key();
925        let a_id = MemberId::from(&a_vk);
926
927        let b_sk = SigningKey::generate(rng);
928        let b_vk = b_sk.verifying_key();
929        let b_id = MemberId::from(&b_vk);
930
931        // Owner → A → B
932        let member_a = AuthorizedMember::new(
933            Member {
934                owner_member_id: owner_id,
935                invited_by: owner_id,
936                member_vk: a_vk,
937            },
938            &owner_sk,
939        );
940        let member_b = AuthorizedMember::new(
941            Member {
942                owner_member_id: owner_id,
943                invited_by: a_id,
944                member_vk: b_vk,
945            },
946            &a_sk,
947        );
948
949        // Only B has a message
950        let msg_b = AuthorizedMessageV1::new(
951            MessageV1 {
952                room_owner: owner_id,
953                author: b_id,
954                time: SystemTime::now(),
955                content: RoomMessageBody::public("Hello from B".to_string()),
956            },
957            &b_sk,
958        );
959
960        let config = Configuration {
961            max_members: 10,
962            max_recent_messages: 100,
963            ..Default::default()
964        };
965        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
966
967        let mut state = ChatRoomStateV1 {
968            configuration: auth_config,
969            members: MembersV1 {
970                members: vec![member_a, member_b],
971            },
972            recent_messages: MessagesV1 {
973                messages: vec![msg_b],
974                ..Default::default()
975            },
976            ..Default::default()
977        };
978
979        state.post_apply_cleanup(&params).unwrap();
980
981        // Both A and B should remain (A is in B's invite chain)
982        assert_eq!(state.members.members.len(), 2);
983        let member_ids: HashSet<MemberId> = state
984            .members
985            .members
986            .iter()
987            .map(|m| m.member.id())
988            .collect();
989        assert!(
990            member_ids.contains(&a_id),
991            "A should be kept (in B's invite chain)"
992        );
993        assert!(
994            member_ids.contains(&b_id),
995            "B should be kept (has messages)"
996        );
997    }
998
999    /// #411 round 3 item B: a member who is the banner of a retained ban is
1000    /// EXEMPT from inactivity-pruning, so their ban does not vanish. (Before
1001    /// round 3 the banner was pruned and the ban persisted anyway; now the ban
1002    /// persists BECAUSE the banner is kept present, which is what keeps it valid
1003    /// under the round-3 "banner must be a current member" sweep.)
1004    #[test]
1005    fn test_banner_exempt_from_inactivity_prune_so_ban_persists() {
1006        let rng = &mut rand::thread_rng();
1007        let owner_sk = SigningKey::generate(rng);
1008        let owner_vk = owner_sk.verifying_key();
1009        let owner_id = MemberId::from(&owner_vk);
1010        let params = ChatRoomParametersV1 { owner: owner_vk };
1011
1012        let a_sk = SigningKey::generate(rng);
1013        let a_vk = a_sk.verifying_key();
1014        let a_id = MemberId::from(&a_vk);
1015
1016        let c_sk = SigningKey::generate(rng);
1017        let c_vk = c_sk.verifying_key();
1018        let c_id = MemberId::from(&c_vk);
1019
1020        // A is a member (invited by owner)
1021        let member_a = AuthorizedMember::new(
1022            Member {
1023                owner_member_id: owner_id,
1024                invited_by: owner_id,
1025                member_vk: a_vk,
1026            },
1027            &owner_sk,
1028        );
1029
1030        // A bans C
1031        let ban_c_by_a = AuthorizedUserBan::new(
1032            UserBan {
1033                owner_member_id: owner_id,
1034                banned_at: SystemTime::now(),
1035                banned_user: c_id,
1036            },
1037            a_id,
1038            &a_sk,
1039        );
1040
1041        let config = Configuration {
1042            max_members: 10,
1043            max_user_bans: 10,
1044            ..Default::default()
1045        };
1046        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1047
1048        // A has no messages, but A is the banner of a retained ban → exempt
1049        // from inactivity-prune (round 3 item B).
1050        let mut state = ChatRoomStateV1 {
1051            configuration: auth_config,
1052            members: MembersV1 {
1053                members: vec![member_a],
1054            },
1055            bans: BansV1(vec![ban_c_by_a]),
1056            ..Default::default()
1057        };
1058
1059        state.post_apply_cleanup(&params).unwrap();
1060
1061        // A is KEPT (exempt as a banner), not pruned.
1062        assert_eq!(
1063            state.members.members.len(),
1064            1,
1065            "A should be exempt from prune"
1066        );
1067        assert_eq!(state.members.members[0].member.id(), a_id);
1068
1069        // A's ban of C persists (banner A is still a current member).
1070        assert_eq!(state.bans.0.len(), 1, "Ban should persist");
1071        assert_eq!(state.bans.0[0].ban.banned_user, c_id);
1072        assert_eq!(state.bans.0[0].banned_by, a_id);
1073    }
1074
1075    /// #411 round 7 / Codex P1 #1: an over-cap ban that WILL be evicted by the
1076    /// `max_user_bans` cap must NOT one-shot-remove its target. If enforcement
1077    /// ran before the cap (the bug), the evicted ban would still have removed a
1078    /// member the capped converged state cannot reproduce — so a peer that only
1079    /// ever sees the capped state keeps that member and the two diverge.
1080    #[test]
1081    fn over_cap_ban_does_not_one_shot_remove() {
1082        let rng = &mut rand::thread_rng();
1083        let owner_sk = SigningKey::generate(rng);
1084        let owner_vk = owner_sk.verifying_key();
1085        let owner_id = MemberId::from(&owner_vk);
1086        let params = ChatRoomParametersV1 { owner: owner_vk };
1087
1088        // Only ONE ban may survive.
1089        let config = Configuration {
1090            max_members: 10,
1091            max_user_bans: 1,
1092            max_recent_messages: 100,
1093            ..Default::default()
1094        };
1095        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1096
1097        // Present member T with a message, so T is retained unless banned.
1098        let t_sk = SigningKey::generate(rng);
1099        let t_vk = t_sk.verifying_key();
1100        let t_id = MemberId::from(&t_vk);
1101        let member_t = AuthorizedMember::new(
1102            Member {
1103                owner_member_id: owner_id,
1104                invited_by: owner_id,
1105                member_vk: t_vk,
1106            },
1107            &owner_sk,
1108        );
1109        let msg_t = AuthorizedMessageV1::new(
1110            MessageV1 {
1111                room_owner: owner_id,
1112                author: t_id,
1113                time: SystemTime::now(),
1114                content: RoomMessageBody::public("hi".to_string()),
1115            },
1116            &t_sk,
1117        );
1118
1119        let base = SystemTime::now();
1120        // Ban of the PRESENT member T, OLDEST → evicted by the cap.
1121        let ban_t = AuthorizedUserBan::new(
1122            UserBan {
1123                owner_member_id: owner_id,
1124                banned_at: base,
1125                banned_user: t_id,
1126            },
1127            owner_id,
1128            &owner_sk,
1129        );
1130        // Ban of an ABSENT user, NEWER → survives the cap.
1131        let absent = MemberId::from(&SigningKey::generate(rng).verifying_key());
1132        let ban_absent = AuthorizedUserBan::new(
1133            UserBan {
1134                owner_member_id: owner_id,
1135                banned_at: base + std::time::Duration::from_secs(10),
1136                banned_user: absent,
1137            },
1138            owner_id,
1139            &owner_sk,
1140        );
1141
1142        let mut state = ChatRoomStateV1 {
1143            configuration: auth_config,
1144            members: MembersV1 {
1145                members: vec![member_t],
1146            },
1147            recent_messages: MessagesV1 {
1148                messages: vec![msg_t],
1149                ..Default::default()
1150            },
1151            bans: BansV1(vec![ban_t, ban_absent]),
1152            ..Default::default()
1153        };
1154
1155        state.post_apply_cleanup(&params).unwrap();
1156
1157        assert!(
1158            state.members.members.iter().any(|m| m.member.id() == t_id),
1159            "an over-cap ban that gets evicted must NOT one-shot-remove its target"
1160        );
1161        assert_eq!(state.bans.0.len(), 1, "capped to max_user_bans");
1162        assert!(
1163            state.bans.0.iter().any(|b| b.ban.banned_user == absent),
1164            "the surviving ban is the newer absent-target one"
1165        );
1166    }
1167
1168    /// #411 round 7 / Codex P1 #2: a banner whose ban the `max_user_bans` cap
1169    /// evicts must lose their prune exemption on the SAME pass. If the cap ran
1170    /// AFTER the exemption (the bug), the banner is kept on pass 1 and pruned on
1171    /// pass 2 — `cleanup(S) != cleanup(cleanup(S))` — permanently diverging peers
1172    /// that run cleanup a different number of times.
1173    #[test]
1174    fn cleanup_is_idempotent_over_cap_evicted_ban() {
1175        let rng = &mut rand::thread_rng();
1176        let owner_sk = SigningKey::generate(rng);
1177        let owner_vk = owner_sk.verifying_key();
1178        let owner_id = MemberId::from(&owner_vk);
1179        let params = ChatRoomParametersV1 { owner: owner_vk };
1180
1181        let config = Configuration {
1182            max_members: 10,
1183            max_user_bans: 2,
1184            max_recent_messages: 100,
1185            ..Default::default()
1186        };
1187        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1188
1189        // Member B: a current member with NO message/DM/secret — only being a
1190        // retained banner could keep them from the inactivity prune.
1191        let b_sk = SigningKey::generate(rng);
1192        let b_vk = b_sk.verifying_key();
1193        let b_id = MemberId::from(&b_vk);
1194        let member_b = AuthorizedMember::new(
1195            Member {
1196                owner_member_id: owner_id,
1197                invited_by: owner_id,
1198                member_vk: b_vk,
1199            },
1200            &owner_sk,
1201        );
1202
1203        let base = SystemTime::now();
1204        // Two owner bans against ABSENT targets, NEWER → survive the cap.
1205        let absent1 = MemberId::from(&SigningKey::generate(rng).verifying_key());
1206        let absent2 = MemberId::from(&SigningKey::generate(rng).verifying_key());
1207        let owner_ban1 = AuthorizedUserBan::new(
1208            UserBan {
1209                owner_member_id: owner_id,
1210                banned_at: base + std::time::Duration::from_secs(10),
1211                banned_user: absent1,
1212            },
1213            owner_id,
1214            &owner_sk,
1215        );
1216        let owner_ban2 = AuthorizedUserBan::new(
1217            UserBan {
1218                owner_member_id: owner_id,
1219                banned_at: base + std::time::Duration::from_secs(11),
1220                banned_user: absent2,
1221            },
1222            owner_id,
1223            &owner_sk,
1224        );
1225        // B's ban against an ABSENT target, OLDEST → evicted by the cap. It is
1226        // "enforcing" (member banner, absent target) so ONLY the cap removes it.
1227        let c_id = MemberId::from(&SigningKey::generate(rng).verifying_key());
1228        let ban_c_by_b = AuthorizedUserBan::new(
1229            UserBan {
1230                owner_member_id: owner_id,
1231                banned_at: base,
1232                banned_user: c_id,
1233            },
1234            b_id,
1235            &b_sk,
1236        );
1237
1238        let state = ChatRoomStateV1 {
1239            configuration: auth_config,
1240            members: MembersV1 {
1241                members: vec![member_b],
1242            },
1243            bans: BansV1(vec![ban_c_by_b, owner_ban1, owner_ban2]),
1244            ..Default::default()
1245        };
1246
1247        let mut once = state.clone();
1248        once.post_apply_cleanup(&params).unwrap();
1249
1250        // B's ban is cap-evicted, so B is NOT exempted and is pruned on pass 1.
1251        assert!(
1252            !once.members.members.iter().any(|m| m.member.id() == b_id),
1253            "over-cap-evicted banner B must be pruned on the FIRST cleanup pass"
1254        );
1255        assert_eq!(once.bans.0.len(), 2, "capped to max_user_bans");
1256
1257        // Idempotence: a second pass changes nothing.
1258        let mut twice = once.clone();
1259        twice.post_apply_cleanup(&params).unwrap();
1260        assert_eq!(once, twice, "post_apply_cleanup must be idempotent");
1261    }
1262
1263    /// #411 round 7 / Codex P2 #4: after a deputy-banned member's messages are
1264    /// swept in cleanup, the PUBLIC `actions_state` cache must be rebuilt so
1265    /// their reaction no longer lingers (the UI's private rebuild is a no-op for
1266    /// a public room).
1267    #[test]
1268    fn banned_member_reaction_removed_from_actions_state_cache() {
1269        let rng = &mut rand::thread_rng();
1270        let owner_sk = SigningKey::generate(rng);
1271        let owner_vk = owner_sk.verifying_key();
1272        let owner_id = MemberId::from(&owner_vk);
1273        let params = ChatRoomParametersV1 { owner: owner_vk };
1274
1275        let config = Configuration {
1276            max_members: 10,
1277            max_user_bans: 10,
1278            max_recent_messages: 100,
1279            ..Default::default()
1280        };
1281        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1282
1283        // M authors a message; R reacts to it. Both are members.
1284        let m_sk = SigningKey::generate(rng);
1285        let m_vk = m_sk.verifying_key();
1286        let m_id = MemberId::from(&m_vk);
1287        let r_sk = SigningKey::generate(rng);
1288        let r_vk = r_sk.verifying_key();
1289        let r_id = MemberId::from(&r_vk);
1290        let member_m = AuthorizedMember::new(
1291            Member {
1292                owner_member_id: owner_id,
1293                invited_by: owner_id,
1294                member_vk: m_vk,
1295            },
1296            &owner_sk,
1297        );
1298        let member_r = AuthorizedMember::new(
1299            Member {
1300                owner_member_id: owner_id,
1301                invited_by: owner_id,
1302                member_vk: r_vk,
1303            },
1304            &owner_sk,
1305        );
1306
1307        let msg1 = AuthorizedMessageV1::new(
1308            MessageV1 {
1309                room_owner: owner_id,
1310                author: m_id,
1311                time: SystemTime::now(),
1312                content: RoomMessageBody::public("hello".to_string()),
1313            },
1314            &m_sk,
1315        );
1316        let msg1_id = msg1.id();
1317        let reaction = AuthorizedMessageV1::new(
1318            MessageV1 {
1319                room_owner: owner_id,
1320                author: r_id,
1321                time: SystemTime::now() + std::time::Duration::from_secs(1),
1322                content: RoomMessageBody::reaction(msg1_id.clone(), "👍".to_string()),
1323            },
1324            &r_sk,
1325        );
1326
1327        let mut state = ChatRoomStateV1 {
1328            configuration: auth_config,
1329            members: MembersV1 {
1330                members: vec![member_m, member_r],
1331            },
1332            recent_messages: MessagesV1 {
1333                messages: vec![msg1.clone(), reaction],
1334                ..Default::default()
1335            },
1336            ..Default::default()
1337        };
1338
1339        // Populate the actions_state cache as `apply_delta` would, WITH R present.
1340        state.recent_messages.rebuild_actions_state();
1341        assert!(
1342            state
1343                .recent_messages
1344                .reactions(&msg1_id)
1345                .and_then(|r| r.get("👍"))
1346                .is_some_and(|v| v.contains(&r_id)),
1347            "sanity: R's reaction is present in the cache before the ban"
1348        );
1349
1350        // Owner bans R; cleanup removes R + their reaction message.
1351        state.bans.0.push(AuthorizedUserBan::new(
1352            UserBan {
1353                owner_member_id: owner_id,
1354                banned_at: SystemTime::now(),
1355                banned_user: r_id,
1356            },
1357            owner_id,
1358            &owner_sk,
1359        ));
1360
1361        state.post_apply_cleanup(&params).unwrap();
1362
1363        assert!(
1364            !state.members.members.iter().any(|m| m.member.id() == r_id),
1365            "R is banned and removed"
1366        );
1367        let lingers = state
1368            .recent_messages
1369            .reactions(&msg1_id)
1370            .and_then(|r| r.get("👍"))
1371            .is_some_and(|v| v.contains(&r_id));
1372        assert!(
1373            !lingers,
1374            "the banned member's reaction must be gone from the rebuilt public \
1375             actions_state cache"
1376        );
1377    }
1378
1379    /// #411 round 7: `post_apply_cleanup` must be idempotent on adversarial
1380    /// states (running it twice yields the same state), otherwise peers that run
1381    /// cleanup a different number of times diverge.
1382    #[test]
1383    fn post_apply_cleanup_is_idempotent_on_adversarial_states() {
1384        let rng = &mut rand::thread_rng();
1385        let owner_sk = SigningKey::generate(rng);
1386        let owner_vk = owner_sk.verifying_key();
1387        let owner_id = MemberId::from(&owner_vk);
1388        let params = ChatRoomParametersV1 { owner: owner_vk };
1389
1390        let cfg = |max_bans: usize| {
1391            AuthorizedConfigurationV1::new(
1392                Configuration {
1393                    max_members: 50,
1394                    max_user_bans: max_bans,
1395                    max_recent_messages: 100,
1396                    ..Default::default()
1397                },
1398                &owner_sk,
1399            )
1400        };
1401        let owner_member = |vk| {
1402            AuthorizedMember::new(
1403                Member {
1404                    owner_member_id: owner_id,
1405                    invited_by: owner_id,
1406                    member_vk: vk,
1407                },
1408                &owner_sk,
1409            )
1410        };
1411        let assert_idem = |state: &ChatRoomStateV1, label: &str| {
1412            let mut once = state.clone();
1413            once.post_apply_cleanup(&params).unwrap();
1414            let mut twice = once.clone();
1415            twice.post_apply_cleanup(&params).unwrap();
1416            assert_eq!(once, twice, "post_apply_cleanup not idempotent: {}", label);
1417        };
1418
1419        // State A: five member-banners of ABSENT targets, over a cap of 2. Three
1420        // are cap-evicted (losing their exemption); two survive.
1421        {
1422            let banners: Vec<SigningKey> = (0..5).map(|_| SigningKey::generate(rng)).collect();
1423            let members: Vec<AuthorizedMember> = banners
1424                .iter()
1425                .map(|sk| owner_member(sk.verifying_key()))
1426                .collect();
1427            let base = SystemTime::now();
1428            let bans: Vec<AuthorizedUserBan> = banners
1429                .iter()
1430                .enumerate()
1431                .map(|(i, sk)| {
1432                    let absent = MemberId::from(&SigningKey::generate(rng).verifying_key());
1433                    AuthorizedUserBan::new(
1434                        UserBan {
1435                            owner_member_id: owner_id,
1436                            banned_at: base + std::time::Duration::from_secs(i as u64),
1437                            banned_user: absent,
1438                        },
1439                        MemberId::from(&sk.verifying_key()),
1440                        sk,
1441                    )
1442                })
1443                .collect();
1444            let state = ChatRoomStateV1 {
1445                configuration: cfg(2),
1446                members: MembersV1 { members },
1447                bans: BansV1(bans),
1448                ..Default::default()
1449            };
1450            assert_idem(&state, "member-banners over cap");
1451        }
1452
1453        // State B: owner cascade-bans A (who had banned B), plus an over-cap
1454        // flood of inert member bans against a present member X.
1455        {
1456            let a_sk = SigningKey::generate(rng);
1457            let a_id = MemberId::from(&a_sk.verifying_key());
1458            let b_id = MemberId::from(&SigningKey::generate(rng).verifying_key());
1459            let x_sk = SigningKey::generate(rng);
1460            let x_id = MemberId::from(&x_sk.verifying_key());
1461            let flood_banners: Vec<SigningKey> =
1462                (0..4).map(|_| SigningKey::generate(rng)).collect();
1463
1464            let mut members = vec![
1465                owner_member(a_sk.verifying_key()),
1466                owner_member(x_sk.verifying_key()),
1467            ];
1468            for sk in &flood_banners {
1469                members.push(owner_member(sk.verifying_key()));
1470            }
1471
1472            let base = SystemTime::now();
1473            let mut bans = vec![
1474                // A banned B (A is a member banner; B absent).
1475                AuthorizedUserBan::new(
1476                    UserBan {
1477                        owner_member_id: owner_id,
1478                        banned_at: base,
1479                        banned_user: b_id,
1480                    },
1481                    a_id,
1482                    &a_sk,
1483                ),
1484                // Owner bans A (cascade removes A → A's ban of B is orphaned).
1485                AuthorizedUserBan::new(
1486                    UserBan {
1487                        owner_member_id: owner_id,
1488                        banned_at: base + std::time::Duration::from_secs(1),
1489                        banned_user: a_id,
1490                    },
1491                    owner_id,
1492                    &owner_sk,
1493                ),
1494            ];
1495            // Inert flood: each floods a ban of present member X (no authority).
1496            for (i, sk) in flood_banners.iter().enumerate() {
1497                bans.push(AuthorizedUserBan::new(
1498                    UserBan {
1499                        owner_member_id: owner_id,
1500                        banned_at: base + std::time::Duration::from_secs(100 + i as u64),
1501                        banned_user: x_id,
1502                    },
1503                    MemberId::from(&sk.verifying_key()),
1504                    sk,
1505                ));
1506            }
1507
1508            let state = ChatRoomStateV1 {
1509                configuration: cfg(3),
1510                members: MembersV1 { members },
1511                bans: BansV1(bans),
1512                ..Default::default()
1513            };
1514            assert_idem(&state, "owner cascade + inert flood over cap");
1515        }
1516    }
1517
1518    #[test]
1519    fn test_member_re_added_with_message() {
1520        let rng = &mut rand::thread_rng();
1521        let owner_sk = SigningKey::generate(rng);
1522        let owner_vk = owner_sk.verifying_key();
1523        let owner_id = MemberId::from(&owner_vk);
1524        let params = ChatRoomParametersV1 { owner: owner_vk };
1525
1526        let a_sk = SigningKey::generate(rng);
1527        let a_vk = a_sk.verifying_key();
1528        let a_id = MemberId::from(&a_vk);
1529
1530        let member_a = AuthorizedMember::new(
1531            Member {
1532                owner_member_id: owner_id,
1533                invited_by: owner_id,
1534                member_vk: a_vk,
1535            },
1536            &owner_sk,
1537        );
1538
1539        let config = Configuration {
1540            max_members: 10,
1541            max_recent_messages: 100,
1542            ..Default::default()
1543        };
1544        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1545
1546        // State with A but no messages
1547        let mut state = ChatRoomStateV1 {
1548            configuration: auth_config,
1549            members: MembersV1 {
1550                members: vec![member_a.clone()],
1551            },
1552            ..Default::default()
1553        };
1554
1555        // Cleanup prunes A
1556        state.post_apply_cleanup(&params).unwrap();
1557        assert!(state.members.members.is_empty(), "A should be pruned");
1558
1559        // Re-add A with a message
1560        state.members.members.push(member_a);
1561        let msg = AuthorizedMessageV1::new(
1562            MessageV1 {
1563                room_owner: owner_id,
1564                author: a_id,
1565                time: SystemTime::now(),
1566                content: RoomMessageBody::public("Hello again!".to_string()),
1567            },
1568            &a_sk,
1569        );
1570        state.recent_messages.messages.push(msg);
1571
1572        // Cleanup should keep A now
1573        state.post_apply_cleanup(&params).unwrap();
1574        assert_eq!(state.members.members.len(), 1, "A should be kept");
1575        assert_eq!(state.members.members[0].member.id(), a_id);
1576    }
1577
1578    #[test]
1579    fn test_member_info_cleaned_after_pruning() {
1580        let rng = &mut rand::thread_rng();
1581        let owner_sk = SigningKey::generate(rng);
1582        let owner_vk = owner_sk.verifying_key();
1583        let owner_id = MemberId::from(&owner_vk);
1584        let params = ChatRoomParametersV1 { owner: owner_vk };
1585
1586        let a_sk = SigningKey::generate(rng);
1587        let a_vk = a_sk.verifying_key();
1588        let a_id = MemberId::from(&a_vk);
1589
1590        let member_a = AuthorizedMember::new(
1591            Member {
1592                owner_member_id: owner_id,
1593                invited_by: owner_id,
1594                member_vk: a_vk,
1595            },
1596            &owner_sk,
1597        );
1598
1599        // Create member_info for A and owner
1600        let a_info = AuthorizedMemberInfo::new_with_member_key(
1601            MemberInfo::new_public(a_id, 1, "Alice".to_string()),
1602            &a_sk,
1603        );
1604        let owner_info = AuthorizedMemberInfo::new(
1605            MemberInfo::new_public(owner_id, 1, "Owner".to_string()),
1606            &owner_sk,
1607        );
1608
1609        let config = Configuration {
1610            max_members: 10,
1611            ..Default::default()
1612        };
1613        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1614
1615        let mut state = ChatRoomStateV1 {
1616            configuration: auth_config,
1617            members: MembersV1 {
1618                members: vec![member_a],
1619            },
1620            member_info: MemberInfoV1 {
1621                member_info: vec![owner_info, a_info],
1622            },
1623            ..Default::default()
1624        };
1625
1626        // A has no messages → gets pruned along with their member_info
1627        state.post_apply_cleanup(&params).unwrap();
1628
1629        assert!(state.members.members.is_empty(), "A should be pruned");
1630        assert_eq!(
1631            state.member_info.member_info.len(),
1632            1,
1633            "Only owner's info should remain"
1634        );
1635        assert_eq!(
1636            state.member_info.member_info[0].member_info.member_id, owner_id,
1637            "Remaining info should be owner's"
1638        );
1639    }
1640
1641    /// Regression test for issue #110 / Bug #3 PR B:
1642    ///
1643    /// A member with an `encrypted_secrets` entry (i.e. the owner has
1644    /// issued them a per-version room-secret blob) must survive
1645    /// `post_apply_cleanup` even if they have not yet authored any
1646    /// messages and have no active DMs. The owner-issued blob is proof
1647    /// that the owner considers them a member, and pruning them on the
1648    /// invitee's first state ingestion is the underlying cause of the
1649    /// "DM to inactive member fails" / "newly-invited member silently
1650    /// pruned" symptom Ivvor reported in Bug #3.
1651    #[test]
1652    fn test_member_with_encrypted_secret_survives_cleanup() {
1653        let rng = &mut rand::thread_rng();
1654        let owner_sk = SigningKey::generate(rng);
1655        let owner_vk = owner_sk.verifying_key();
1656        let owner_id = MemberId::from(&owner_vk);
1657        let params = ChatRoomParametersV1 { owner: owner_vk };
1658
1659        let a_sk = SigningKey::generate(rng);
1660        let a_vk = a_sk.verifying_key();
1661        let a_id = MemberId::from(&a_vk);
1662
1663        let member_a = AuthorizedMember::new(
1664            Member {
1665                owner_member_id: owner_id,
1666                invited_by: owner_id,
1667                member_vk: a_vk,
1668            },
1669            &owner_sk,
1670        );
1671
1672        // A has NO messages and NO DMs — under the pre-fix rules they
1673        // would be pruned by post_apply_cleanup. The owner-issued
1674        // encrypted secret is the only evidence of membership.
1675        let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
1676            member_id: a_id,
1677            secret_version: 0,
1678            ciphertext: vec![0u8; 16], // dummy ciphertext — signature is what counts
1679            nonce: [0u8; 12],
1680            sender_ephemeral_public_key: [0u8; 32],
1681            provider: owner_id,
1682        };
1683        let authorized_secret = crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1684            secret_for_a,
1685            &owner_sk,
1686        );
1687
1688        let config = Configuration {
1689            max_members: 10,
1690            max_recent_messages: 100,
1691            ..Default::default()
1692        };
1693        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1694
1695        let mut state = ChatRoomStateV1 {
1696            configuration: auth_config,
1697            members: MembersV1 {
1698                members: vec![member_a],
1699            },
1700            secrets: crate::room_state::secret::RoomSecretsV1 {
1701                current_version: 0,
1702                versions: vec![],
1703                encrypted_secrets: vec![authorized_secret],
1704            },
1705            ..Default::default()
1706        };
1707
1708        state.post_apply_cleanup(&params).unwrap();
1709
1710        assert_eq!(
1711            state.members.members.len(),
1712            1,
1713            "A should survive cleanup because they have an encrypted_secrets entry"
1714        );
1715        assert_eq!(state.members.members[0].member.id(), a_id);
1716    }
1717
1718    /// IMPORTANT #4 (PR #272 review round 2): a member who is BOTH
1719    /// banned AND has a stale `encrypted_secrets` blob must still be
1720    /// pruned by `post_apply_cleanup`. The exemption introduced for
1721    /// issue #110 grants survival on the strength of the owner's
1722    /// blob, but bans must override — a ban is the owner's later,
1723    /// authoritative statement that this member is no longer trusted.
1724    ///
1725    /// The `members_by_id.contains_key(recipient_id)` guard at the
1726    /// cleanup site keeps this safe: the ban delta runs through the
1727    /// member-prune path before `post_apply_cleanup`'s `required_ids`
1728    /// collection, so by the time we check the exemption set, the
1729    /// banned member is no longer in `members_by_id` and the
1730    /// exemption clause is short-circuited. This test pins that
1731    /// behaviour against any future regression that loosens the
1732    /// guard.
1733    #[test]
1734    fn test_banned_member_with_encrypted_secret_is_still_pruned() {
1735        use crate::room_state::ban::{AuthorizedUserBan, UserBan};
1736        use std::time::SystemTime;
1737
1738        let rng = &mut rand::thread_rng();
1739        let owner_sk = SigningKey::generate(rng);
1740        let owner_vk = owner_sk.verifying_key();
1741        let owner_id = MemberId::from(&owner_vk);
1742        let params = ChatRoomParametersV1 { owner: owner_vk };
1743
1744        let a_sk = SigningKey::generate(rng);
1745        let a_vk = a_sk.verifying_key();
1746        let a_id = MemberId::from(&a_vk);
1747
1748        let member_a = AuthorizedMember::new(
1749            Member {
1750                owner_member_id: owner_id,
1751                invited_by: owner_id,
1752                member_vk: a_vk,
1753            },
1754            &owner_sk,
1755        );
1756
1757        let ban = AuthorizedUserBan::new(
1758            UserBan {
1759                owner_member_id: owner_id,
1760                banned_at: SystemTime::now(),
1761                banned_user: a_id,
1762            },
1763            owner_id,
1764            &owner_sk,
1765        );
1766
1767        // Owner issued a v0 blob for A, then banned A. The blob
1768        // outlives the ban in the state (a peer might receive both
1769        // deltas in one batch). Without proper handling, the
1770        // exemption would resurrect A.
1771        let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
1772            member_id: a_id,
1773            secret_version: 0,
1774            ciphertext: vec![0u8; 16],
1775            nonce: [0u8; 12],
1776            sender_ephemeral_public_key: [0u8; 32],
1777            provider: owner_id,
1778        };
1779        let authorized_secret = crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1780            secret_for_a,
1781            &owner_sk,
1782        );
1783
1784        let config = Configuration {
1785            max_members: 10,
1786            max_recent_messages: 100,
1787            ..Default::default()
1788        };
1789        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1790
1791        let mut state = ChatRoomStateV1 {
1792            configuration: auth_config,
1793            members: MembersV1 {
1794                members: vec![member_a],
1795            },
1796            bans: crate::room_state::ban::BansV1(vec![ban]),
1797            secrets: crate::room_state::secret::RoomSecretsV1 {
1798                current_version: 0,
1799                versions: vec![],
1800                encrypted_secrets: vec![authorized_secret],
1801            },
1802            ..Default::default()
1803        };
1804
1805        // The owner-side flow is: apply ban delta -> members.apply_delta
1806        // removes A from members -> post_apply_cleanup runs. We
1807        // simulate the post-ban-prune state by manually removing A
1808        // from members (matching what `MembersV1::apply_delta` does
1809        // when it sees the ban), then run cleanup.
1810        state.members.members.retain(|m| m.member.id() != a_id);
1811
1812        state.post_apply_cleanup(&params).unwrap();
1813
1814        assert!(
1815            state.members.members.is_empty(),
1816            "banned member A must NOT be resurrected by post_apply_cleanup's \
1817             encrypted_secrets exemption — see IMPORTANT #4 on PR #272 review round 2"
1818        );
1819        // The ban itself must persist.
1820        assert_eq!(state.bans.0.len(), 1);
1821        assert_eq!(state.bans.0[0].ban.banned_user, a_id);
1822    }
1823
1824    /// IMPORTANT #5 (PR #272 review round 2): the
1825    /// `encrypted_secrets` exemption from `post_apply_cleanup` must
1826    /// be SCOPED to the current secret version. A member who has
1827    /// only old-version blobs and hasn't been re-issued at
1828    /// `current_version` is "stale" by the same definition as a
1829    /// member who joined and never authored, and must be pruned.
1830    ///
1831    /// Without this TTL, every ever-recipient + their entire
1832    /// invite-chain ancestor set would be exempt from cleanup
1833    /// forever — defeating the whole point of the inactivity prune.
1834    #[test]
1835    fn test_stale_secret_recipient_is_pruned_after_rotation() {
1836        use crate::room_state::privacy::RoomCipherSpec;
1837        use crate::room_state::secret::{AuthorizedSecretVersionRecord, SecretVersionRecordV1};
1838        use std::time::SystemTime;
1839
1840        let rng = &mut rand::thread_rng();
1841        let owner_sk = SigningKey::generate(rng);
1842        let owner_vk = owner_sk.verifying_key();
1843        let owner_id = MemberId::from(&owner_vk);
1844        let params = ChatRoomParametersV1 { owner: owner_vk };
1845
1846        let a_sk = SigningKey::generate(rng);
1847        let a_vk = a_sk.verifying_key();
1848        let a_id = MemberId::from(&a_vk);
1849
1850        let member_a = AuthorizedMember::new(
1851            Member {
1852                owner_member_id: owner_id,
1853                invited_by: owner_id,
1854                member_vk: a_vk,
1855            },
1856            &owner_sk,
1857        );
1858
1859        // A only has a v0 blob. The room has since rotated to v1
1860        // and A was not re-issued (e.g. they left / were
1861        // implicitly inactive at rotation time).
1862        let secret_for_a = crate::room_state::secret::EncryptedSecretForMemberV1 {
1863            member_id: a_id,
1864            secret_version: 0,
1865            ciphertext: vec![0u8; 16],
1866            nonce: [0u8; 12],
1867            sender_ephemeral_public_key: [0u8; 32],
1868            provider: owner_id,
1869        };
1870        let authorized_secret_v0 =
1871            crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1872                secret_for_a,
1873                &owner_sk,
1874            );
1875
1876        let v1_record = AuthorizedSecretVersionRecord::new(
1877            SecretVersionRecordV1 {
1878                version: 1,
1879                cipher_spec: RoomCipherSpec::Aes256Gcm,
1880                created_at: SystemTime::now(),
1881            },
1882            &owner_sk,
1883        );
1884
1885        let config = Configuration {
1886            max_members: 10,
1887            max_recent_messages: 100,
1888            ..Default::default()
1889        };
1890        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1891
1892        let mut state = ChatRoomStateV1 {
1893            configuration: auth_config,
1894            members: MembersV1 {
1895                members: vec![member_a],
1896            },
1897            secrets: crate::room_state::secret::RoomSecretsV1 {
1898                current_version: 1,
1899                versions: vec![v1_record],
1900                encrypted_secrets: vec![authorized_secret_v0],
1901            },
1902            ..Default::default()
1903        };
1904
1905        state.post_apply_cleanup(&params).unwrap();
1906
1907        assert!(
1908            state.members.members.is_empty(),
1909            "member A with ONLY a stale v0 blob (no v1 re-issue, no messages, no \
1910             DMs) must be pruned — see IMPORTANT #5 on PR #272 review round 2"
1911        );
1912    }
1913
1914    /// IMPORTANT #6 (PR #272 review round 2): ban-race convergence
1915    /// across peers receiving deltas in different orders. Both
1916    /// orderings — (add-X, ban-X) and (ban-X, add-X) — must
1917    /// converge with X removed, regardless of whether the
1918    /// owner-issued `encrypted_secret` for X arrives before or
1919    /// after the ban.
1920    ///
1921    /// This is the same convergence test pattern PR #240 used for
1922    /// DMs but applied to the new encrypted_secrets exemption.
1923    /// Without this test, a future regression that loosens the
1924    /// "members_by_id.contains_key" guard could leak X back into
1925    /// state via the exemption when the deltas land in the
1926    /// "wrong" order.
1927    #[test]
1928    fn test_ban_race_with_encrypted_secret_converges_to_pruned() {
1929        use crate::room_state::ban::{AuthorizedUserBan, UserBan};
1930        use std::time::SystemTime;
1931
1932        let rng = &mut rand::thread_rng();
1933        let owner_sk = SigningKey::generate(rng);
1934        let owner_vk = owner_sk.verifying_key();
1935        let owner_id = MemberId::from(&owner_vk);
1936        let params = ChatRoomParametersV1 { owner: owner_vk };
1937
1938        let x_sk = SigningKey::generate(rng);
1939        let x_vk = x_sk.verifying_key();
1940        let x_id = MemberId::from(&x_vk);
1941
1942        let member_x = AuthorizedMember::new(
1943            Member {
1944                owner_member_id: owner_id,
1945                invited_by: owner_id,
1946                member_vk: x_vk,
1947            },
1948            &owner_sk,
1949        );
1950
1951        let ban_x = AuthorizedUserBan::new(
1952            UserBan {
1953                owner_member_id: owner_id,
1954                banned_at: SystemTime::now(),
1955                banned_user: x_id,
1956            },
1957            owner_id,
1958            &owner_sk,
1959        );
1960
1961        let secret_for_x = crate::room_state::secret::EncryptedSecretForMemberV1 {
1962            member_id: x_id,
1963            secret_version: 0,
1964            ciphertext: vec![0u8; 16],
1965            nonce: [0u8; 12],
1966            sender_ephemeral_public_key: [0u8; 32],
1967            provider: owner_id,
1968        };
1969        let authorized_secret_x =
1970            crate::room_state::secret::AuthorizedEncryptedSecretForMember::new(
1971                secret_for_x,
1972                &owner_sk,
1973            );
1974
1975        let config = Configuration {
1976            max_members: 10,
1977            max_recent_messages: 100,
1978            ..Default::default()
1979        };
1980        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
1981
1982        // Build the FINAL converged state both peers should arrive
1983        // at: X is banned, X is not in members, the v0
1984        // encrypted_secret for X may or may not be present
1985        // depending on whether peer's secrets state pruned it.
1986        // We simulate the post-merge state where both deltas have
1987        // landed; the in-flight blob for X is still in state when
1988        // post_apply_cleanup runs.
1989        //
1990        // Peer A: applied [add-X@t0, ban-X@t1] — members.apply_delta
1991        // saw the ban and removed X from members. Then the
1992        // secrets delta arrived with a v0 blob for X. Final state:
1993        // members = [], bans = [ban-X], encrypted_secrets = [(x, 0)].
1994        let mut peer_a_state = ChatRoomStateV1 {
1995            configuration: auth_config.clone(),
1996            members: MembersV1 { members: vec![] },
1997            bans: crate::room_state::ban::BansV1(vec![ban_x.clone()]),
1998            secrets: crate::room_state::secret::RoomSecretsV1 {
1999                current_version: 0,
2000                versions: vec![],
2001                encrypted_secrets: vec![authorized_secret_x.clone()],
2002            },
2003            ..Default::default()
2004        };
2005        peer_a_state.post_apply_cleanup(&params).unwrap();
2006        assert!(
2007            peer_a_state.members.members.is_empty(),
2008            "peer A: X must remain pruned despite the encrypted_secret being present"
2009        );
2010
2011        // Peer B: applied [ban-X@t1, add-X@t0]. ban-X was applied
2012        // first; add-X arrived later but was rejected by
2013        // `MembersV1::apply_delta` because X is in the ban list.
2014        // Then the secrets delta arrived with a v0 blob for X.
2015        // Final state matches peer A's.
2016        let mut peer_b_state = ChatRoomStateV1 {
2017            configuration: auth_config,
2018            members: MembersV1 { members: vec![] },
2019            bans: crate::room_state::ban::BansV1(vec![ban_x]),
2020            secrets: crate::room_state::secret::RoomSecretsV1 {
2021                current_version: 0,
2022                versions: vec![],
2023                encrypted_secrets: vec![authorized_secret_x],
2024            },
2025            ..Default::default()
2026        };
2027        peer_b_state.post_apply_cleanup(&params).unwrap();
2028        assert!(
2029            peer_b_state.members.members.is_empty(),
2030            "peer B: X must remain pruned despite the encrypted_secret being present"
2031        );
2032
2033        // The two peers must converge to byte-identical members /
2034        // bans / encrypted_secrets state.
2035        assert_eq!(peer_a_state.members, peer_b_state.members);
2036        assert_eq!(peer_a_state.bans, peer_b_state.bans);
2037        assert_eq!(peer_a_state.secrets, peer_b_state.secrets);
2038
2039        // Suppress unused-variable lints — `member_x` is the seed
2040        // we used to derive `x_id` / `x_vk`; the convergence test
2041        // checks the AFTER-merge state where members is already
2042        // empty by construction.
2043        let _ = member_x;
2044    }
2045
2046    #[test]
2047    fn test_state_with_none_deltas() {
2048        let (state, parameters, owner_signing_key) = create_empty_chat_room_state();
2049
2050        // Create a modified room_state with no changes (all deltas should be None)
2051        let modified_state = state.clone();
2052
2053        // Apply the delta
2054        let summary = state.summarize(&state, &parameters);
2055        let delta = modified_state.delta(&state, &parameters, &summary);
2056
2057        assert!(
2058            delta.is_none(),
2059            "Delta should be None when no changes are made"
2060        );
2061
2062        // Now, let's modify only one field and check if other deltas are None
2063        let mut partially_modified_state = state.clone();
2064        let new_config = Configuration {
2065            configuration_version: 2,
2066            ..partially_modified_state.configuration.configuration.clone()
2067        };
2068        partially_modified_state.configuration =
2069            AuthorizedConfigurationV1::new(new_config, &owner_signing_key);
2070
2071        let summary = state.summarize(&state, &parameters);
2072        let delta = partially_modified_state
2073            .delta(&state, &parameters, &summary)
2074            .unwrap();
2075
2076        // Check that only the configuration delta is Some, and others are None
2077        assert!(
2078            delta.configuration.is_some(),
2079            "Configuration delta should be Some"
2080        );
2081        assert!(delta.bans.is_none(), "Bans delta should be None");
2082        assert!(delta.members.is_none(), "Members delta should be None");
2083        assert!(
2084            delta.member_info.is_none(),
2085            "Member info delta should be None"
2086        );
2087        assert!(
2088            delta.recent_messages.is_none(),
2089            "Recent messages delta should be None"
2090        );
2091        assert!(delta.upgrade.is_none(), "Upgrade delta should be None");
2092
2093        // Apply the partial delta
2094        let mut new_state = state.clone();
2095        new_state
2096            .apply_delta(&state, &parameters, &Some(delta))
2097            .unwrap();
2098
2099        assert_eq!(
2100            new_state, partially_modified_state,
2101            "State should be partially modified"
2102        );
2103    }
2104
2105    /// Regression test for freenet/river#127: the contract-migration upgrade
2106    /// pointer is delivered to the old contract as a minimal `apply_delta`,
2107    /// NOT as a full-state UPDATE.
2108    ///
2109    /// A full `UpdateData::State` is run through the old contract's
2110    /// `validate_state` -> `ChatRoomStateV1::verify`. The old code built that
2111    /// state with `..Default::default()`, whose `configuration` is unsigned,
2112    /// so verification failed with "Invalid signature" and the pointer never
2113    /// landed. Applying only the `upgrade` field as a delta runs
2114    /// `OptionalUpgradeV1::apply_delta`, which validates just the upgrade
2115    /// signature against the contract's owner parameter.
2116    #[test]
2117    fn test_upgrade_pointer_applies_as_delta() {
2118        use crate::room_state::upgrade::{AuthorizedUpgradeV1, UpgradeV1};
2119
2120        let (state, parameters, owner_signing_key) = create_empty_chat_room_state();
2121        // Sanity: the baseline room state is itself valid and has no pointer.
2122        assert!(
2123            state.verify(&state, &parameters).is_ok(),
2124            "baseline room state should verify"
2125        );
2126        assert!(
2127            state.upgrade.0.is_none(),
2128            "baseline room state should have no upgrade pointer"
2129        );
2130
2131        let upgrade = UpgradeV1 {
2132            owner_member_id: MemberId::from(&parameters.owner),
2133            version: 1,
2134            new_chatroom_address: blake3::Hash::from([7u8; 32]),
2135        };
2136        let authorized = AuthorizedUpgradeV1::new(upgrade, &owner_signing_key);
2137
2138        // FIX: apply the upgrade pointer as a minimal delta — the path the old
2139        // contract takes for `UpdateData::Delta`. The pointer must land and the
2140        // resulting state must still verify.
2141        let delta = ChatRoomStateV1Delta {
2142            upgrade: Some(authorized.clone()),
2143            ..Default::default()
2144        };
2145        let mut updated = state.clone();
2146        updated
2147            .apply_delta(&state, &parameters, &Some(delta))
2148            .expect("applying the upgrade-pointer delta must succeed");
2149        assert_eq!(
2150            updated.upgrade.0.as_ref(),
2151            Some(&authorized),
2152            "the upgrade pointer must land on the old contract's state"
2153        );
2154        assert!(
2155            updated.verify(&updated, &parameters).is_ok(),
2156            "the state with the upgrade pointer applied must still verify"
2157        );
2158
2159        // Why not a full-state UPDATE: a `..Default::default()` state — the old
2160        // (#127) approach — fails `verify` because its default `configuration`
2161        // is unsigned, so the runtime's `validate_state` rejected it.
2162        let buggy_full_state = ChatRoomStateV1 {
2163            upgrade: OptionalUpgradeV1(Some(authorized)),
2164            ..Default::default()
2165        };
2166        assert!(
2167            buggy_full_state
2168                .verify(&buggy_full_state, &parameters)
2169                .is_err(),
2170            "a `..Default::default()` full-state upgrade must fail verification (the #127 bug)"
2171        );
2172    }
2173
2174    /// #411 round 8 item C: a state carrying DUPLICATE member_info records for a
2175    /// member (which `verify` accepts) is collapsed by `post_apply_cleanup` to
2176    /// exactly one canonical (highest-rank) record per member — killing the
2177    /// duplicate-SET byte-divergence between peers and bounding stored size.
2178    #[test]
2179    fn post_apply_cleanup_dedups_member_info_to_canonical() {
2180        use crate::room_state::member_info::{AuthorizedMemberInfo, MemberInfo};
2181
2182        let rng = &mut rand::thread_rng();
2183        let owner_sk = SigningKey::generate(rng);
2184        let owner_vk = owner_sk.verifying_key();
2185        let owner_id = MemberId::from(&owner_vk);
2186        let params = ChatRoomParametersV1 { owner: owner_vk };
2187
2188        let config = Configuration {
2189            max_members: 10,
2190            max_recent_messages: 100,
2191            ..Default::default()
2192        };
2193        let auth_config = AuthorizedConfigurationV1::new(config, &owner_sk);
2194
2195        // Member M kept present (has a message), with TWO member_info records:
2196        // grant @ v1 (a deputy) and revoke @ v2 (empty).
2197        let m_sk = SigningKey::generate(rng);
2198        let m_vk = m_sk.verifying_key();
2199        let m_id = MemberId::from(&m_vk);
2200        let member_m = AuthorizedMember::new(
2201            Member {
2202                owner_member_id: owner_id,
2203                invited_by: owner_id,
2204                member_vk: m_vk,
2205            },
2206            &owner_sk,
2207        );
2208        let msg_m = AuthorizedMessageV1::new(
2209            MessageV1 {
2210                room_owner: owner_id,
2211                author: m_id,
2212                time: SystemTime::now(),
2213                content: RoomMessageBody::public("hi".to_string()),
2214            },
2215            &m_sk,
2216        );
2217
2218        let dep = MemberId::from(&SigningKey::generate(rng).verifying_key());
2219        let mut g = MemberInfo::new_public(m_id, 1, "nick".to_string());
2220        g.deputies = vec![dep];
2221        let grant = AuthorizedMemberInfo::new_with_member_key(g, &m_sk);
2222        let mut r = MemberInfo::new_public(m_id, 2, "nick".to_string());
2223        r.deputies = vec![];
2224        let revoke = AuthorizedMemberInfo::new_with_member_key(r, &m_sk);
2225
2226        let mut state = ChatRoomStateV1 {
2227            configuration: auth_config,
2228            members: MembersV1 {
2229                members: vec![member_m],
2230            },
2231            member_info: MemberInfoV1 {
2232                // Duplicate: grant FIRST so a naive first-match would keep it.
2233                member_info: vec![grant, revoke],
2234            },
2235            recent_messages: MessagesV1 {
2236                messages: vec![msg_m],
2237                ..Default::default()
2238            },
2239            ..Default::default()
2240        };
2241
2242        state.post_apply_cleanup(&params).unwrap();
2243
2244        let recs: Vec<_> = state
2245            .member_info
2246            .member_info
2247            .iter()
2248            .filter(|i| i.member_info.member_id == m_id)
2249            .collect();
2250        assert_eq!(
2251            recs.len(),
2252            1,
2253            "exactly one member_info record for M after cleanup"
2254        );
2255        assert_eq!(
2256            recs[0].member_info.version, 2,
2257            "the surviving record is the v2 revoke"
2258        );
2259        assert!(
2260            recs[0].member_info.deputies.is_empty(),
2261            "the canonical (revoke) record has no deputies — revoked authority is not resurrected"
2262        );
2263        assert_eq!(
2264            state.member_info.deputies_of(m_id),
2265            &[] as &[MemberId],
2266            "deputies_of agrees with the deduped canonical record"
2267        );
2268    }
2269}