From b23bf5d8a9fc4a9a36a0d796e5fb9bb4d890181b Mon Sep 17 00:00:00 2001 From: Jean Date: Thu, 16 Jul 2026 11:17:43 +0000 Subject: [PATCH] test: property-test gossip convergence between two nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh peer that backfills a node's log via the version vector must reach the same global state (accounts, akills, forbids, groups); channels/jupes are Local and correctly don't replicate. Guards the core federation guarantee — a distinct invariant from same-node replay (exercises missing_for + version_vector + cross-node ingest). --- src/engine/db/tests.rs | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/engine/db/tests.rs b/src/engine/db/tests.rs index dcefe33..b9a7b27 100644 --- a/src/engine/db/tests.rs +++ b/src/engine/db/tests.rs @@ -624,6 +624,45 @@ assert_eq!(before, after, "compaction must preserve every persisted field"); } + // Gossip convergence: a fresh peer that backfills a node's log via the version + // vector must arrive at the same GLOBAL state (accounts + network bans + groups). + // Channels/jupes are Local and deliberately don't replicate, so they're excluded. + #[test] + fn gossip_converges_global_state_to_a_peer() { + let pa = tmp("gossipA"); + let mut a = Db::open(&pa, "N1"); + a.scram_iterations = 4096; + a.register("alice", "pw", Some("a@x.io".into())).unwrap(); + a.register("bob", "pw", None).unwrap(); + a.set_greet("alice", "hi there").unwrap(); + a.set_account_kill("alice", false).unwrap(); + a.suspend_account("bob", "op", "bad", None).unwrap(); + a.akill_add("G", "*@evil", "op", "spam", None).unwrap(); + a.forbid_add("NICK", "bad*", "op", "squat").unwrap(); + a.group_register("!g", "alice").unwrap(); + a.group_set_flags("!g", "bob", "").unwrap(); + + let pb = tmp("gossipB"); + let mut b = Db::open(&pb, "N2"); + for entry in a.missing_for(&b.version_vector()) { + b.ingest(entry).unwrap(); + } + + let gsnap = |db: &Db| { + let mut ac: Vec<_> = db.accounts().cloned().collect(); + ac.sort_by(|x, y| x.name.cmp(&y.name)); + let mut ak = db.akills(); + ak.sort_by(|x, y| x.mask.cmp(&y.mask)); + let mut fb = db.forbids(); + fb.sort_by(|x, y| x.mask.cmp(&y.mask)); + let mut gn = db.groups(); + gn.sort(); + let gv: Vec<_> = gn.iter().filter_map(|n| db.group(n)).collect(); + format!("{ac:?}\n{ak:?}\n{fb:?}\n{gv:?}") + }; + assert_eq!(gsnap(&a), gsnap(&b), "peer B must converge to A's global state"); + } + // Dropping an account erases every reference to it — channel access and // successorship, group membership and foundership — so a later re-registration // of the same name can't inherit its standing. Holds live and after replay.