Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/
testing.rs

1//! In-process multi-instance test harness (Tier 0).
2//!
3//! Standing up several real `Instance`s that sync with one another takes a pile
4//! of identical boilerplate: create a user and key, enable sync, register a
5//! transport, start serving, resolve the bound address. [`Cluster`] does *that*
6//! plumbing and nothing else — it hands back wired peers and leaves every policy
7//! decision (auth, what to write, how to drive sync) to the test.
8//!
9//! That split is deliberate. A correctness harness for authenticated CRDT sync
10//! must keep the two things it exists to exercise — the **transport** and the
11//! **auth** — under the test's control, not baked into the setup:
12//!
13//! - **Transport is a seam.** [`ClusterBuilder::transport`] takes any
14//!   [`TestTransport`]; the default is [`HttpLoopback`]. A controllable in-memory
15//!   transport (deliver / reorder / drop / single-step — Tier 1) is a drop-in
16//!   here, which is the point: Tier 1 extends this, it doesn't replace it.
17//! - **Auth is the test's.** The harness never grants keys or permissions for
18//!   you. A peer exposes its `User`, key id, and key name; the test creates its
19//!   database with whatever auth posture it's exercising. [`add_auth_keys`] and
20//!   [`set_global_auth_key`] are policy-neutral *tools* the test composes — they
21//!   apply the keys you pass, they don't choose them.
22//!
23//! What the harness owns is plumbing only: wiring peers, marking a tree
24//! sync-enabled ([`Peer::serve`]), driving an exchange ([`Cluster::exchange`]),
25//! and observing convergence ([`Cluster::converged`]). It does not hold your
26//! databases — the test opens and keeps those itself.
27//!
28//! This is **topology A** (multi-peer sync): N independent `Instance`s, each
29//! owning an in-memory backend. Sync is driven explicitly (no background timers),
30//! so a test fully orders the exchange. The multi-client / single-service
31//! topology is a separate harness that lands with the `service` feature.
32//!
33//! Gated behind `cfg(any(test, feature = "testing"))` alongside [`FixedClock`]
34//! and [`Instance::create_backend_with_clock`]; never compiled into a release build.
35//!
36//! ```no_run
37//! # async fn ex() -> eidetica::Result<()> {
38//! use eidetica::{
39//!     auth::{Permission, types::AuthKey},
40//!     crdt::Doc,
41//!     testing::{Cluster, set_global_auth_key},
42//!     user::types::SyncSettings,
43//! };
44//!
45//! let mut net = Cluster::builder().peers(2).build().await?;
46//!
47//! // Peer 0 creates a database with auth the *test* chooses, then serves it.
48//! let key0 = net.peer(0).key_id().clone();
49//! let mut settings = Doc::new();
50//! settings.set("name", "chat");
51//! let db = net.peer_mut(0).user_mut().create_database(settings, &key0).await?;
52//! let room = db.root_id().clone();
53//! set_global_auth_key(&db, AuthKey::active(None, Permission::Write(10))).await?;
54//! net.peer_mut(0).serve(&room).await?;
55//!
56//! // Peer 1 bootstraps with its own key, then converges against peer 0.
57//! let key1 = net.peer(1).key_id().clone();
58//! let signing_key1 = net.peer(1).user().get_signing_key(&key1)?;
59//! let name1 = net.peer(1).key_name().to_string();
60//! let addr0 = net.peer(0).address().clone();
61//! net.peer(1)
62//!     .sync()
63//!     .sync_with_peer_for_bootstrap_with_key(
64//!         &addr0,
65//!         &room,
66//!         &signing_key1,
67//!         &name1,
68//!         Permission::Write(10),
69//!     )
70//!     .await?;
71//! net.peer_mut(1)
72//!     .user_mut()
73//!     .track_database(room.clone(), &key1, SyncSettings::disabled())
74//!     .await?;
75//!
76//! net.exchange(1, 0, &room).await?;
77//! assert!(net.converged(&[0, 1], &room).await?);
78//! # Ok(()) }
79//! ```
80
81use std::sync::Arc;
82
83use async_trait::async_trait;
84
85use crate::{
86    Database, Entry, Instance, NewUser, Result, Snapshot,
87    auth::{Permission, crypto::PublicKey, types::AuthKey},
88    backend::{BackendImpl, VerificationStatus, database::InMemory},
89    clock::{Clock, FixedClock},
90    crdt::Doc,
91    entry::ID,
92    sync::{
93        Address, Sync,
94        error::SyncError,
95        handler::SyncHandler,
96        protocol::{RequestContext, SyncRequest, SyncResponse},
97        transports::{SyncTransport, TransportBuilder, http::HttpTransport},
98    },
99    user::{User, types::SyncSettings},
100};
101
102/// Display name given to every peer's signing key. Exposed per peer via
103/// [`Peer::key_name`] so a test can name it in a bootstrap request.
104const KEY_NAME: &str = "test-key";
105
106/// How a peer makes itself reachable to other peers. The one seam Tier 1 swaps:
107/// implement this over an in-memory, controllable network and the rest of the
108/// harness is unchanged.
109#[async_trait]
110pub trait TestTransport: Send + std::marker::Sync {
111    /// Register this transport on `sync`, start serving, and return the address
112    /// other peers use to reach it. Called once per peer at build time.
113    async fn serve(&self, sync: &Sync) -> Result<Address>;
114}
115
116/// Default [`TestTransport`]: HTTP over an OS-assigned loopback port.
117#[derive(Debug, Default, Clone)]
118pub struct HttpLoopback;
119
120#[async_trait]
121impl TestTransport for HttpLoopback {
122    async fn serve(&self, sync: &Sync) -> Result<Address> {
123        sync.register_transport("http", HttpTransport::builder().bind("127.0.0.1:0"))
124            .await?;
125        sync.accept_connections().await?;
126        Ok(Address::http(sync.get_server_address().await?))
127    }
128}
129
130/// Builder for a [`Cluster`]. Obtain via [`Cluster::builder`].
131pub struct ClusterBuilder {
132    peers: usize,
133    clock: Option<Arc<dyn Clock>>,
134    transport: Arc<dyn TestTransport>,
135}
136
137impl ClusterBuilder {
138    /// Number of peers (independent `Instance`s) to start. Defaults to 2.
139    pub fn peers(mut self, n: usize) -> Self {
140        self.peers = n;
141        self
142    }
143
144    /// Share a single clock across every peer (e.g. a [`FixedClock`] the test
145    /// drives by hand). When unset, each peer gets its own fresh `FixedClock`,
146    /// mirroring the standard `test_instance()` setup.
147    pub fn clock(mut self, clock: Arc<dyn Clock>) -> Self {
148        self.clock = Some(clock);
149        self
150    }
151
152    /// Use a custom [`TestTransport`] for every peer. Defaults to [`HttpLoopback`].
153    pub fn transport(mut self, transport: Arc<dyn TestTransport>) -> Self {
154        self.transport = transport;
155        self
156    }
157
158    /// Build the cluster: for each peer, open an instance, create a user and
159    /// signing key, enable sync, and serve over the configured transport.
160    pub async fn build(self) -> Result<Cluster> {
161        let mut peers = Vec::with_capacity(self.peers);
162        for i in 0..self.peers {
163            let clock: Arc<dyn Clock> = match &self.clock {
164                Some(shared) => shared.clone(),
165                None => Arc::new(FixedClock::default()),
166            };
167            // Create the backend and bootstrap its admin user in one step; each
168            // peer is a fresh in-memory instance, so its only user is this one.
169            let (instance, mut user) = Instance::create_backend_with_clock(
170                Box::new(InMemory::new()),
171                clock,
172                NewUser::passwordless(format!("peer{i}")),
173            )
174            .await?;
175            let key_id = user.add_private_key(Some(KEY_NAME)).await?;
176
177            instance.enable_sync().await?;
178            let sync = instance
179                .sync()
180                .expect("sync handle present immediately after enable_sync");
181            let address = self.transport.serve(&sync).await?;
182
183            peers.push(Peer {
184                instance,
185                user,
186                key_id,
187                sync,
188                address,
189            });
190        }
191        Ok(Cluster { peers })
192    }
193}
194
195/// A set of in-process eidetica peers wired for multi-peer sync. Each peer is a
196/// full `Instance` with its own backend. The cluster owns the wiring; the test
197/// owns the databases, the auth, and the order of operations.
198pub struct Cluster {
199    peers: Vec<Peer>,
200}
201
202impl Cluster {
203    /// Start building a cluster. Defaults: 2 peers, per-peer `FixedClock`,
204    /// [`HttpLoopback`] transport.
205    pub fn builder() -> ClusterBuilder {
206        ClusterBuilder {
207            peers: 2,
208            clock: None,
209            transport: Arc::new(HttpLoopback),
210        }
211    }
212
213    /// Number of peers in the cluster.
214    pub fn len(&self) -> usize {
215        self.peers.len()
216    }
217
218    /// Whether the cluster has no peers.
219    pub fn is_empty(&self) -> bool {
220        self.peers.is_empty()
221    }
222
223    /// Shared access to a peer (instance, user, key, address).
224    pub fn peer(&self, i: usize) -> &Peer {
225        &self.peers[i]
226    }
227
228    /// Mutable access to a peer, for `create_database` / `open_database` on its
229    /// `User`.
230    pub fn peer_mut(&mut self, i: usize) -> &mut Peer {
231        &mut self.peers[i]
232    }
233
234    /// Have peer `to` bootstrap `tree` from peer `from`: request the tree with
235    /// `to`'s own key (asking for `permission`), flush, and track it locally
236    /// (sync disabled — the test turns on [`serve`]/[`auto_sync`] if it wants
237    /// more). The joining-peer dance as one call.
238    ///
239    /// `permission` is the access level `to` requests; the harness does not pick
240    /// it — auth posture stays the test's. `from` must already be serving `tree`
241    /// (see [`Peer::serve`]) with a policy that admits this request.
242    ///
243    /// [`serve`]: Peer::serve
244    /// [`auto_sync`]: Cluster::auto_sync
245    pub async fn bootstrap(
246        &mut self,
247        from: usize,
248        to: usize,
249        tree: &ID,
250        permission: Permission,
251    ) -> Result<()> {
252        let from_addr = self.peers[from].address.clone();
253        let to_key = self.peers[to].key_id.clone();
254        let to_signing_key = self.peers[to]
255            .user
256            .get_signing_key(&to_key)
257            .expect("peer key must be unlocked");
258        self.peers[to]
259            .sync
260            .sync_with_peer_for_bootstrap_with_key(
261                &from_addr,
262                tree,
263                &to_signing_key,
264                KEY_NAME,
265                permission,
266            )
267            .await?;
268        self.peers[to].sync.flush().await?;
269        self.peers[to]
270            .user
271            .track_database(tree.clone(), &to_key, SyncSettings::disabled())
272            .await?;
273        Ok(())
274    }
275
276    /// Drive a sync exchange for `tree`, initiated by peer `from` against peer
277    /// `to`. eidetica's `sync_with_peer` exchanges in *both* directions, so after
278    /// this both peers hold each other's entries for the tree. Both sync queues
279    /// are flushed; flush errors propagate (this is a bug-finding tool — it does
280    /// not swallow them).
281    pub async fn exchange(&self, from: usize, to: usize, tree: &ID) -> Result<()> {
282        let to_addr = self.peers[to].address.clone();
283        self.peers[from]
284            .sync
285            .sync_with_peer(&to_addr, Some(tree))
286            .await?;
287        self.peers[from].sync.flush().await?;
288        self.peers[to].sync.flush().await?;
289        Ok(())
290    }
291
292    /// Turn on **background, automatic** sync of `tree` between peers `a` and `b`,
293    /// in both directions. After this a commit on either peer is queued for the
294    /// other automatically (sync-on-commit) — no per-write [`exchange`] call. Use
295    /// [`flush`] to push the queue immediately, or let the background interval
296    /// carry it.
297    ///
298    /// Both peers must already hold `tree` (e.g. one [`Peer::serve`]d it and the
299    /// other bootstrapped it). This wires the peer relationship both ways
300    /// (register peer + dial-back address + per-tree sync target) and re-tracks
301    /// the tree as `on_commit` on each side.
302    ///
303    /// [`exchange`]: Cluster::exchange
304    /// [`flush`]: Cluster::flush
305    pub async fn auto_sync(&mut self, a: usize, b: usize, tree: &ID) -> Result<()> {
306        let a_pub = self.peers[a].sync.get_device_pubkey()?;
307        let b_pub = self.peers[b].sync.get_device_pubkey()?;
308        let a_addr = self.peers[a].address.clone();
309        let b_addr = self.peers[b].address.clone();
310        let a_key = self.peers[a].key_id.clone();
311        let b_key = self.peers[b].key_id.clone();
312
313        // Each peer learns how to reach the other. A prior bootstrap may already
314        // have registered the peer, so registration is idempotent here.
315        register_peer_idempotent(&self.peers[a].sync, &b_pub).await?;
316        self.peers[a].sync.add_peer_address(&b_pub, b_addr).await?;
317        register_peer_idempotent(&self.peers[b].sync, &a_pub).await?;
318        self.peers[b].sync.add_peer_address(&a_pub, a_addr).await?;
319
320        // Each peer tracks the tree on-commit and targets the other for it.
321        self.peers[a]
322            .user
323            .track_database(tree.clone(), &a_key, SyncSettings::on_commit())
324            .await?;
325        self.peers[a].sync.add_tree_sync(&b_pub, tree).await?;
326        self.peers[b]
327            .user
328            .track_database(tree.clone(), &b_key, SyncSettings::on_commit())
329            .await?;
330        self.peers[b].sync.add_tree_sync(&a_pub, tree).await?;
331        Ok(())
332    }
333
334    /// [`auto_sync`] every peer pair in the cluster — a full mesh, so a commit on
335    /// any peer fans out to all the others. Both peers of every pair must already
336    /// hold `tree`. Does not flush; call [`flush_all`] to drain the setup pushes.
337    ///
338    /// [`auto_sync`]: Cluster::auto_sync
339    /// [`flush_all`]: Cluster::flush_all
340    pub async fn auto_sync_all(&mut self, tree: &ID) -> Result<()> {
341        let n = self.len();
342        for a in 0..n {
343            for b in (a + 1)..n {
344                self.auto_sync(a, b, tree).await?;
345            }
346        }
347        Ok(())
348    }
349
350    /// Push peer `peer`'s pending auto-sync queue to its targets now, instead of
351    /// waiting for the background interval. The deterministic barrier for
352    /// auto-sync tests: commit, `flush`, assert.
353    pub async fn flush(&self, peer: usize) -> Result<()> {
354        self.peers[peer].sync.flush().await
355    }
356
357    /// Drain the whole cluster: repeatedly [`flush`] every peer until all pending
358    /// auto-sync work has propagated everywhere, then settle.
359    ///
360    /// A single pass over the peers is **not** enough in general. `flush` visits
361    /// each peer once, in index order, so a pass advances an in-flight entry at
362    /// most one hop along its sync path (and only in the index direction — an
363    /// entry that must travel "backwards", from a higher-indexed peer to a lower
364    /// one, waits for the next pass). One pass suffices only when every peer
365    /// pushes directly to every other (a full mesh); a sparser topology — a relay
366    /// chain — needs up to one pass per hop. `len() + 1` passes covers the worst
367    /// case, since no propagation path through `len()` peers is longer than
368    /// `len() - 1` hops. This is the whole-cluster barrier: after it, every
369    /// deliverable entry has reached every peer.
370    ///
371    /// [`flush`]: Cluster::flush
372    pub async fn flush_all(&self) -> Result<()> {
373        for _ in 0..(self.len() + 1) {
374            for peer in 0..self.len() {
375                self.flush(peer).await?;
376            }
377        }
378        Ok(())
379    }
380
381    /// The [`Snapshot`] peer `peer` currently holds for `tree` — the canonical
382    /// (sorted, deduplicated) tip set identifying its state. [`Snapshot::EMPTY`]
383    /// if the peer has never seen the tree.
384    pub async fn snapshot(&self, peer: usize, tree: &ID) -> Result<Snapshot> {
385        self.peers[peer].instance.backend().snapshot(tree).await
386    }
387
388    /// True if the named `peers` all agree on `tree`'s [`Snapshot`] — the
389    /// convergence invariant. The caller names which peers should have converged;
390    /// a peer that never received the tree has an empty snapshot and will not
391    /// match. Comparison is `Snapshot` set-equality, so tip order never matters.
392    pub async fn converged(&self, peers: &[usize], tree: &ID) -> Result<bool> {
393        let mut reference: Option<Snapshot> = None;
394        for &i in peers {
395            let snapshot = self.snapshot(i, tree).await?;
396            match &reference {
397                None => reference = Some(snapshot),
398                Some(r) if *r != snapshot => return Ok(false),
399                Some(_) => {}
400            }
401        }
402        Ok(true)
403    }
404
405    /// Whether *every* peer agrees on `tree`'s tip set — the common convergence
406    /// check. Shorthand for [`converged`] over all peers; the explicit
407    /// `&[peers]` form stays for partition tests that expect only a subset to
408    /// agree.
409    ///
410    /// [`converged`]: Cluster::converged
411    pub async fn converged_all(&self, tree: &ID) -> Result<bool> {
412        let all: Vec<usize> = (0..self.peers.len()).collect();
413        self.converged(&all, tree).await
414    }
415
416    /// Drive bidirectional [`exchange`] across every peer pair, round after
417    /// round, until the whole cluster holds an identical tip set for `tree` —
418    /// then return `true`. Bounded to `peers` rounds (a complete graph converges
419    /// in one, the budget is slack for safety); returns the final convergence
420    /// status if the budget is spent without settling.
421    ///
422    /// Quiescent only: there must be no concurrent writes while this runs (it
423    /// has no way to observe them). Every peer must already hold and serve
424    /// `tree` so it can answer an exchange — `bootstrap` then [`Peer::serve`] on
425    /// each joiner. The fixpoint barrier the N-peer / partition-heal tests
426    /// assert against.
427    ///
428    /// [`exchange`]: Cluster::exchange
429    pub async fn converge(&self, tree: &ID) -> Result<bool> {
430        let n = self.peers.len();
431        for _ in 0..n.max(1) {
432            if self.converged_all(tree).await? {
433                return Ok(true);
434            }
435            for i in 0..n {
436                for j in (i + 1)..n {
437                    self.exchange(i, j, tree).await?;
438                }
439            }
440        }
441        self.converged_all(tree).await
442    }
443
444    // ===== invariant assertions =====
445    //
446    // Tip-set equality ([`converged`]) proves two peers *agree*, but it is a weak
447    // invariant: it says nothing about *what* they agreed on. Two peers can share
448    // a tip set yet differ below it, or converge onto a state that quietly dropped
449    // a signed entry, or store a received entry as `Failed`. These walk the full
450    // entry set behind the tips and assert the properties tip equality misses.
451    // They panic (not return `false`) with a diagnostic — invariant violation is a
452    // test failure, and the message should name the offending peer and entry.
453
454    /// The concrete local backend engine for peer `peer`. `Cluster` peers always
455    /// run on an in-memory backend, so the off-seam raw reads the invariant checks
456    /// need — the full entry dump ([`BackendImpl::get_tree`]) and per-entry
457    /// verification status — are always reachable through it.
458    fn local_engine(&self, peer: usize) -> Arc<dyn BackendImpl> {
459        self.peers[peer]
460            .instance
461            .backend()
462            .local_engine()
463            .expect("Cluster peers run on a local in-memory backend")
464    }
465
466    /// Every entry peer `peer` holds for `tree`, in id order. The full DAG of the
467    /// tree — settings, auth, and every store — not just the tips.
468    pub async fn entries(&self, peer: usize, tree: &ID) -> Result<Vec<Entry>> {
469        let mut entries = self.local_engine(peer).get_tree(tree).await?;
470        entries.sort_by_key(|e| e.id());
471        Ok(entries)
472    }
473
474    /// The id of every entry peer `peer` holds for `tree`, sorted.
475    pub async fn entry_ids(&self, peer: usize, tree: &ID) -> Result<Vec<ID>> {
476        Ok(self
477            .entries(peer, tree)
478            .await?
479            .into_iter()
480            .map(|e| e.id())
481            .collect())
482    }
483
484    /// Assert no peer in `peers` is missing an entry another holds for `tree` —
485    /// the merge converged onto the *union* of histories, never silently dropping
486    /// one peer's signed entry. Stronger than [`converged`], which only compares
487    /// tips.
488    ///
489    /// Limitation: if *every* peer dropped the same entry the union is also short
490    /// it, so this can't see that loss — use [`assert_all_present`] with an
491    /// externally-known id set for the absolute form.
492    ///
493    /// [`converged`]: Cluster::converged
494    /// [`assert_all_present`]: Cluster::assert_all_present
495    pub async fn assert_no_lost_entries(&self, peers: &[usize], tree: &ID) -> Result<()> {
496        use std::collections::BTreeSet;
497        let mut union: BTreeSet<ID> = BTreeSet::new();
498        let mut per_peer: Vec<(usize, BTreeSet<ID>)> = Vec::with_capacity(peers.len());
499        for &p in peers {
500            let ids: BTreeSet<ID> = self.entry_ids(p, tree).await?.into_iter().collect();
501            union.extend(ids.iter().cloned());
502            per_peer.push((p, ids));
503        }
504        for (p, ids) in &per_peer {
505            let missing: Vec<&ID> = union.difference(ids).collect();
506            assert!(
507                missing.is_empty(),
508                "peer {p} lost {} entr{} other peers hold for the tree: {missing:?}",
509                missing.len(),
510                if missing.len() == 1 { "y" } else { "ies" },
511            );
512        }
513        Ok(())
514    }
515
516    /// Assert every id in `expected` is present on every peer in `peers`. The
517    /// absolute form of [`assert_no_lost_entries`]: the test names entries it knows
518    /// were committed (e.g. ids captured from its own writes) and demands they
519    /// survive the merge everywhere.
520    ///
521    /// [`assert_no_lost_entries`]: Cluster::assert_no_lost_entries
522    pub async fn assert_all_present(
523        &self,
524        peers: &[usize],
525        tree: &ID,
526        expected: &[ID],
527    ) -> Result<()> {
528        for &p in peers {
529            let ids: std::collections::BTreeSet<ID> =
530                self.entry_ids(p, tree).await?.into_iter().collect();
531            let missing: Vec<&ID> = expected.iter().filter(|id| !ids.contains(id)).collect();
532            assert!(
533                missing.is_empty(),
534                "peer {p} is missing expected entries: {missing:?}",
535            );
536        }
537        Ok(())
538    }
539
540    /// Assert every entry peer `peer` holds for `tree` carries a well-formed
541    /// signature. A synced CRDT under global auth must never store an unsigned or
542    /// malformed-signature entry; this catches one that slipped through.
543    pub async fn assert_all_signed(&self, peer: usize, tree: &ID) -> Result<()> {
544        for e in self.entries(peer, tree).await? {
545            assert!(
546                !e.sig.is_unsigned(),
547                "peer {peer} holds an unsigned entry: {}",
548                e.id(),
549            );
550            if let Some(reason) = e.sig.malformed_reason() {
551                panic!(
552                    "peer {peer} holds a malformed-signature entry {}: {reason}",
553                    e.id(),
554                );
555            }
556        }
557        Ok(())
558    }
559
560    /// Assert no entry peer `peer` holds for `tree` is in the `Failed` verification
561    /// state — every entry, including those received over sync, verified against
562    /// the tree's auth. Stronger than tip equality: a peer can converge on the
563    /// right tips while having stored a received entry that does not verify.
564    ///
565    /// This is only a meaningful convergence invariant once sync runs a per-entry
566    /// verification pass that promotes received entries after their signing
567    /// context arrives. On a build where sync ingestion records a placeholder
568    /// status instead of a real signature check (see the TODO on
569    /// [`VerificationStatus`] and `docs/src/design/verification.md`), the stored
570    /// status does not reflect verification and this assertion should not be used
571    /// — a bootstrapped peer legitimately holds entries marked `Failed` that no
572    /// pass has yet promoted. Provided for the harness's forward path: exercise it
573    /// once verification-on-ingest is in place.
574    ///
575    /// [`VerificationStatus`]: crate::backend::VerificationStatus
576    pub async fn assert_all_verified(&self, peer: usize, tree: &ID) -> Result<()> {
577        let engine = self.local_engine(peer);
578        for e in self.entries(peer, tree).await? {
579            let status = engine.get_verification_status(&e.id()).await?;
580            assert!(
581                matches!(status, VerificationStatus::Verified),
582                "peer {peer} stored entry {} as {status:?}, expected Verified",
583                e.id(),
584            );
585        }
586        Ok(())
587    }
588}
589
590/// One peer in a [`Cluster`]: a full `Instance` plus the handles a test needs to
591/// act as that peer. It does **not** hold the peer's application databases — the
592/// test opens and keeps those.
593pub struct Peer {
594    instance: Instance,
595    user: User,
596    key_id: PublicKey,
597    sync: Arc<Sync>,
598    address: Address,
599}
600
601impl Peer {
602    /// This peer's `Instance`.
603    pub fn instance(&self) -> &Instance {
604        &self.instance
605    }
606
607    /// This peer's logged-in user session.
608    pub fn user(&self) -> &User {
609        &self.user
610    }
611
612    /// Mutable user session, for `create_database` / `open_database` directly.
613    pub fn user_mut(&mut self) -> &mut User {
614        &mut self.user
615    }
616
617    /// This peer's signing key id (the `SigKey` for its database operations).
618    pub fn key_id(&self) -> &PublicKey {
619        &self.key_id
620    }
621
622    /// The display name of this peer's signing key, for naming it in a bootstrap
623    /// request.
624    pub fn key_name(&self) -> &str {
625        KEY_NAME
626    }
627
628    /// This peer's sync handle.
629    pub fn sync(&self) -> &Arc<Sync> {
630        &self.sync
631    }
632
633    /// The address other peers use to reach this peer.
634    pub fn address(&self) -> &Address {
635        &self.address
636    }
637
638    /// Mark `tree` sync-enabled on this peer so its sync handler will serve it to
639    /// bootstrapping peers. Delegates to [`User::enable_sync`], which flips the
640    /// user's preference and recomputes the host's combined sync state — the same
641    /// path a real consumer takes. The database must already be tracked (it is, on
642    /// any peer that created it via `create_database` or joined it via
643    /// [`Cluster::bootstrap`]). Pure plumbing: set whatever auth the test needs on
644    /// the database *before* calling this.
645    ///
646    /// [`User::enable_sync`]: crate::user::User::enable_sync
647    pub async fn serve(&mut self, tree: &ID) -> Result<()> {
648        self.user.enable_sync(tree).await
649    }
650}
651
652/// Register `pubkey` as a peer of `sync`, treating an already-registered peer as
653/// success — a prior bootstrap commonly registers it first.
654async fn register_peer_idempotent(sync: &Sync, pubkey: &PublicKey) -> Result<()> {
655    match sync.register_peer(pubkey, Some("peer")).await {
656        Ok(()) => Ok(()),
657        Err(crate::Error::Sync(e))
658            if matches!(*e, crate::sync::error::SyncError::PeerAlreadyExists(_)) =>
659        {
660            Ok(())
661        }
662        Err(e) => Err(e),
663    }
664}
665
666// ===== auth tools (policy-neutral: apply the keys the caller passes) =====
667
668/// Apply per-key auth to a database via a settings transaction. The caller
669/// chooses the keys and permissions; this just writes them.
670pub async fn add_auth_keys(db: &Database, keys: &[(&PublicKey, AuthKey)]) -> Result<()> {
671    let txn = db.new_transaction().await?;
672    let settings = txn.get_settings()?;
673    for (pubkey, key) in keys {
674        settings.set_auth_key(pubkey, key.clone()).await?;
675    }
676    txn.commit().await?;
677    Ok(())
678}
679
680/// Set the global (wildcard) auth key on a database via a settings transaction.
681/// The caller chooses the permission level.
682pub async fn set_global_auth_key(db: &Database, key: AuthKey) -> Result<()> {
683    let txn = db.new_transaction().await?;
684    let settings = txn.get_settings()?;
685    settings.set_global_auth_key(key).await?;
686    txn.commit().await?;
687    Ok(())
688}
689
690// ===== SimTransport: in-memory, controllable transport (Tier 1 seam) =====
691//
692// `HttpLoopback` is real HTTP over loopback: it delivers in wired order, so
693// "convergence is order-independent" is unprovable and a partition can only be
694// modelled coarsely (don't call `exchange`). `SimTransport` swaps the one seam
695// the harness left open — [`TestTransport`] — for an in-process fabric that
696// routes a [`SyncRequest`] straight to the target peer's [`SyncHandler`]: no
697// sockets, no ports, deterministic, and *controllable*. A test holds a
698// [`SimNetwork`] handle and partitions links mid-run.
699//
700// This is Tier 1 of the harness. It does not replace Tier 0 — it plugs into it:
701// `Cluster::builder().transport(Arc::new(SimLoopback::new(net.clone())))`.
702
703/// In-memory message fabric shared by every [`SimTransport`] in a cluster, and
704/// the control handle a test uses to inject faults. A drop-in for
705/// [`HttpLoopback`] via [`ClusterBuilder::transport`] that additionally lets a
706/// test [`partition`] links and [`heal`] them.
707///
708/// `Clone` is a shared handle (an `Arc` inside): the copy a test keeps and the
709/// copies inside each peer's transport all see the same fabric.
710///
711/// [`partition`]: SimNetwork::partition
712/// [`heal`]: SimNetwork::heal
713#[derive(Clone, Default)]
714pub struct SimNetwork {
715    inner: Arc<std::sync::Mutex<SimState>>,
716}
717
718#[derive(Default)]
719struct SimState {
720    /// Peer address -> that peer's serving handler, populated when it serves.
721    handlers: std::collections::HashMap<String, Arc<dyn SyncHandler>>,
722    /// Directed links currently dropping traffic: `(from_addr, to_addr)`.
723    blocked: std::collections::HashSet<(String, String)>,
724    /// Monotonic id source for peer addresses.
725    next_id: usize,
726    /// When set, `SendEntries` pushes are captured in `queue` instead of being
727    /// delivered to the receiver's handler inline. Request/response traffic
728    /// (handshake, tree-sync) always delivers inline regardless.
729    manual_delivery: bool,
730    /// Captured, not-yet-delivered messages, in send order. Only populated in
731    /// manual-delivery mode.
732    queue: Vec<InFlight>,
733    /// Monotonic id source for captured messages (delivery handles).
734    next_seq: usize,
735}
736
737/// A `SendEntries` push captured in manual-delivery mode, awaiting an explicit
738/// [`SimNetwork::deliver_one`] / [`deliver`](SimNetwork::deliver) / etc. The
739/// sender already received an optimistic `Ack`, so this models a message
740/// in-flight on the wire: the network decides when, in what order, and how many
741/// times the receiver actually sees it.
742struct InFlight {
743    /// Stable delivery handle, unique for the life of the fabric.
744    seq: usize,
745    /// Sender's sim address (becomes the receiver's `remote_address`).
746    from: String,
747    /// Receiver's sim address (whose handler will process the request).
748    to: String,
749    /// The captured request — always a `SyncRequest::SendEntries`.
750    request: SyncRequest,
751}
752
753impl SimNetwork {
754    /// A fresh, empty fabric.
755    pub fn new() -> Self {
756        Self::default()
757    }
758
759    fn lock(&self) -> std::sync::MutexGuard<'_, SimState> {
760        self.inner.lock().expect("SimNetwork mutex poisoned")
761    }
762
763    /// Hand out the next unique peer address (`sim-peer-N`, in serve order).
764    fn alloc_address(&self) -> String {
765        let mut s = self.lock();
766        let id = s.next_id;
767        s.next_id += 1;
768        format!("sim-peer-{id}")
769    }
770
771    fn register(&self, address: &str, handler: Arc<dyn SyncHandler>) {
772        self.lock().handlers.insert(address.to_string(), handler);
773    }
774
775    fn unregister(&self, address: &str) {
776        self.lock().handlers.remove(address);
777    }
778
779    /// Clone out the handler for `address` (drops the lock before any await).
780    fn handler_for(&self, address: &str) -> Option<Arc<dyn SyncHandler>> {
781        self.lock().handlers.get(address).cloned()
782    }
783
784    fn is_blocked(&self, from: &str, to: &str) -> bool {
785        self.lock()
786            .blocked
787            .contains(&(from.to_string(), to.to_string()))
788    }
789
790    /// If manual-delivery is on, capture `request` as an in-flight message from
791    /// `from` to `to` and return `true` (the caller answers the sender with an
792    /// optimistic `Ack`). Otherwise return `false` and let the caller deliver
793    /// inline. Called only for `SendEntries`.
794    fn capture(&self, from: &str, to: &Address, request: &SyncRequest) -> bool {
795        let mut s = self.lock();
796        if !s.manual_delivery {
797            return false;
798        }
799        let seq = s.next_seq;
800        s.next_seq += 1;
801        s.queue.push(InFlight {
802            seq,
803            from: from.to_string(),
804            to: to.address.clone(),
805            request: request.clone(),
806        });
807        true
808    }
809
810    /// Drop all traffic between `a` and `b` in *both* directions until [`heal`].
811    /// A send across a blocked link fails as a connection error, so an
812    /// auto-sync peer's queued entries stay pending and redeliver after heal —
813    /// a message-level partition, finer than withholding `exchange` calls.
814    ///
815    /// [`heal`]: SimNetwork::heal
816    pub fn partition(&self, a: &Address, b: &Address) {
817        let mut s = self.lock();
818        s.blocked.insert((a.address.clone(), b.address.clone()));
819        s.blocked.insert((b.address.clone(), a.address.clone()));
820    }
821
822    /// Restore traffic between `a` and `b` (both directions).
823    pub fn heal(&self, a: &Address, b: &Address) {
824        let mut s = self.lock();
825        s.blocked.remove(&(a.address.clone(), b.address.clone()));
826        s.blocked.remove(&(b.address.clone(), a.address.clone()));
827    }
828
829    /// Restore every link in the fabric.
830    pub fn heal_all(&self) {
831        self.lock().blocked.clear();
832    }
833
834    // ----- store-and-forward delivery control -----
835    //
836    // By default the fabric delivers every request inline (synchronous, in wired
837    // order) — same as `HttpLoopback`, just without sockets. Turn on
838    // *manual delivery* and `SendEntries` pushes are instead captured as
839    // [`InFlight`] messages, and the test decides when each one reaches its
840    // receiver. The sender still gets an immediate `Ack`, so an undelivered
841    // message looks delivered-from-the-sender's-side — a message that's left the
842    // sender but not yet arrived. This is what makes reorder, duplicate, and
843    // selective drop expressible; a real socket transport can't hold a message
844    // mid-flight under test control.
845    //
846    // Handshake and tree-sync are request/response and carry data the caller
847    // needs back, so they always deliver inline — only the fire-and-forget
848    // `SendEntries` push is deferrable. Bootstrap and `exchange` therefore work
849    // unchanged in manual mode; only auto-sync's entry pushes get captured.
850
851    /// Capture `SendEntries` pushes instead of delivering them inline (`true`),
852    /// or return to inline delivery (`false`). Flip this *after* setup
853    /// (bootstrap / `auto_sync`) so only the entry pushes a test cares about get
854    /// captured. Turning it back off does not flush the queue — already-captured
855    /// messages still need an explicit deliver.
856    ///
857    /// The two fault families compose with **partition taking precedence**: a
858    /// send across a [`partition`](Self::partition)ed link fails as a connection
859    /// error *before* capture is considered, so it parks in the retry queue rather
860    /// than the in-flight queue. Manual delivery only ever captures pushes on
861    /// links that are up. Tests generally use one family or the other, not both at
862    /// once.
863    pub fn set_manual_delivery(&self, manual: bool) {
864        self.lock().manual_delivery = manual;
865    }
866
867    /// The delivery handles of every captured-but-undelivered message, in the
868    /// order they were sent. Pass these to [`deliver`](Self::deliver),
869    /// [`duplicate`](Self::duplicate) or [`drop_message`](Self::drop_message) to
870    /// drive an out-of-order, duplicated, or lossy schedule.
871    pub fn pending(&self) -> Vec<usize> {
872        self.lock().queue.iter().map(|m| m.seq).collect()
873    }
874
875    /// Pop the captured message with handle `seq` (its request, sender, and
876    /// receiver). Returns `None` if no such message is queued.
877    fn take(&self, seq: usize) -> Option<InFlight> {
878        let mut s = self.lock();
879        let idx = s.queue.iter().position(|m| m.seq == seq)?;
880        Some(s.queue.remove(idx))
881    }
882
883    /// Deliver one captured message to its receiver: look up the receiver's
884    /// handler and run the request through it, exactly as an inline send would.
885    /// The response is discarded — the sender already got its optimistic `Ack`.
886    /// Returns `false` if the receiver is no longer serving (the message is
887    /// effectively lost).
888    async fn deliver_inflight(&self, msg: InFlight) -> bool {
889        let Some(handler) = self.handler_for(&msg.to) else {
890            return false;
891        };
892        let context = RequestContext {
893            remote_address: Some(Address::new(SimTransport::TRANSPORT_TYPE, msg.from)),
894            // Only tree-sync carries a pubkey, and that never enters the queue.
895            peer_pubkey: None,
896        };
897        handler.handle_request(&msg.request, &context).await;
898        true
899    }
900
901    /// Deliver the captured message `seq` to its receiver and remove it from the
902    /// queue. Delivering in an order other than [`pending`](Self::pending)
903    /// returned is how a test reorders the wire. Returns `false` if no such
904    /// message is queued (or its receiver has stopped).
905    pub async fn deliver(&self, seq: usize) -> bool {
906        match self.take(seq) {
907            Some(msg) => self.deliver_inflight(msg).await,
908            None => false,
909        }
910    }
911
912    /// Deliver the oldest captured message. Returns `false` when the queue is
913    /// empty.
914    pub async fn deliver_one(&self) -> bool {
915        let seq = match self.lock().queue.first() {
916            Some(m) => m.seq,
917            None => return false,
918        };
919        self.deliver(seq).await
920    }
921
922    /// Deliver every captured message in send order, draining the queue. Returns
923    /// the number delivered. (Delivery never enqueues more — the receiver's
924    /// handler processes entries, it doesn't push back through this fabric.)
925    pub async fn deliver_all(&self) -> usize {
926        let mut n = 0;
927        while self.deliver_one().await {
928            n += 1;
929        }
930        n
931    }
932
933    /// Clone captured message `seq` so it will be delivered a second time,
934    /// modelling a duplicate on the wire. The copy gets a fresh handle and lands
935    /// at the back of the queue; the original stays put. Returns the new handle,
936    /// or `None` if `seq` isn't queued. Idempotent sync must converge regardless.
937    pub fn duplicate(&self, seq: usize) -> Option<usize> {
938        let mut s = self.lock();
939        let original = s.queue.iter().find(|m| m.seq == seq)?;
940        let copy = InFlight {
941            seq: s.next_seq,
942            from: original.from.clone(),
943            to: original.to.clone(),
944            request: original.request.clone(),
945        };
946        let new_seq = copy.seq;
947        s.next_seq += 1;
948        s.queue.push(copy);
949        Some(new_seq)
950    }
951
952    /// Drop captured message `seq` without delivering it — a lost packet.
953    /// Returns `true` if a message was removed.
954    pub fn drop_message(&self, seq: usize) -> bool {
955        self.take(seq).is_some()
956    }
957
958    /// Drop every captured message without delivering. Returns the number lost.
959    pub fn drop_all(&self) -> usize {
960        let mut s = self.lock();
961        let n = s.queue.len();
962        s.queue.clear();
963        n
964    }
965}
966
967/// [`TestTransport`] backed by a [`SimNetwork`]: an in-memory drop-in for
968/// [`HttpLoopback`]. Build a cluster over it with
969/// `Cluster::builder().transport(Arc::new(SimLoopback::new(net.clone())))` and
970/// keep `net` to drive partitions.
971pub struct SimLoopback {
972    network: SimNetwork,
973}
974
975impl SimLoopback {
976    /// Wrap a [`SimNetwork`]. Share one network across the cluster (clone the
977    /// handle) so the test and every peer route through the same fabric.
978    pub fn new(network: SimNetwork) -> Self {
979        Self { network }
980    }
981}
982
983#[async_trait]
984impl TestTransport for SimLoopback {
985    async fn serve(&self, sync: &Sync) -> Result<Address> {
986        let address = self.network.alloc_address();
987        sync.register_transport(
988            "sim",
989            SimTransportBuilder {
990                address: address.clone(),
991                network: self.network.clone(),
992            },
993        )
994        .await?;
995        // accept_connections awaits StartServer, which calls start_server and
996        // registers our handler before returning — no post-serve race.
997        sync.accept_connections().await?;
998        Ok(Address::new(SimTransport::TRANSPORT_TYPE, address))
999    }
1000}
1001
1002/// Builder that hands the peer's address + shared fabric to its [`SimTransport`].
1003struct SimTransportBuilder {
1004    address: String,
1005    network: SimNetwork,
1006}
1007
1008#[async_trait]
1009impl TransportBuilder for SimTransportBuilder {
1010    type Transport = SimTransport;
1011
1012    async fn build(self, _persisted: Doc) -> Result<(Self::Transport, Option<Doc>)> {
1013        Ok((
1014            SimTransport {
1015                address: self.address,
1016                network: self.network,
1017                running: false,
1018            },
1019            None,
1020        ))
1021    }
1022}
1023
1024/// In-memory [`SyncTransport`]. Routes a [`SyncRequest`] straight to the target
1025/// peer's [`SyncHandler`] through the shared [`SimNetwork`] — no sockets, no
1026/// serialization — and honors the network's partition state.
1027pub struct SimTransport {
1028    /// This peer's own sim address (the key its handler is registered under).
1029    address: String,
1030    network: SimNetwork,
1031    running: bool,
1032}
1033
1034impl SimTransport {
1035    const TRANSPORT_TYPE: &'static str = "sim";
1036}
1037
1038#[async_trait]
1039impl SyncTransport for SimTransport {
1040    fn transport_type(&self) -> &'static str {
1041        Self::TRANSPORT_TYPE
1042    }
1043
1044    fn can_handle_address(&self, address: &Address) -> bool {
1045        address.transport_type == Self::TRANSPORT_TYPE
1046    }
1047
1048    async fn start_server(&mut self, handler: Arc<dyn SyncHandler>) -> Result<()> {
1049        self.network.register(&self.address, handler);
1050        self.running = true;
1051        Ok(())
1052    }
1053
1054    async fn stop_server(&mut self) -> Result<()> {
1055        self.network.unregister(&self.address);
1056        self.running = false;
1057        Ok(())
1058    }
1059
1060    async fn send_request(&self, address: &Address, request: &SyncRequest) -> Result<SyncResponse> {
1061        if !self.can_handle_address(address) {
1062            return Err(SyncError::UnsupportedTransport {
1063                transport_type: address.transport_type.clone(),
1064            }
1065            .into());
1066        }
1067        // A partitioned link looks like a connection failure to the sender; the
1068        // background sync layer keeps the entries queued for a later flush.
1069        if self.network.is_blocked(&self.address, &address.address) {
1070            return Err(SyncError::ConnectionFailed {
1071                address: address.address.clone(),
1072                reason: "sim link partitioned".to_string(),
1073            }
1074            .into());
1075        }
1076        // In manual-delivery mode, capture entry pushes as in-flight messages
1077        // and report success to the sender (an optimistic Ack — see
1078        // `SimNetwork::set_manual_delivery`). Request/response traffic falls
1079        // through to inline delivery so its result reaches the caller.
1080        if matches!(request, SyncRequest::SendEntries(_))
1081            && self.network.capture(&self.address, address, request)
1082        {
1083            return Ok(SyncResponse::Ack);
1084        }
1085
1086        let handler = self.network.handler_for(&address.address).ok_or_else(|| {
1087            SyncError::ConnectionFailed {
1088                address: address.address.clone(),
1089                reason: "no sim peer serving this address".to_string(),
1090            }
1091        })?;
1092
1093        // Mirror the HTTP transport's context: only SyncTree carries a pubkey.
1094        let peer_pubkey = match request {
1095            SyncRequest::SyncTree(r) => r.peer_pubkey.clone(),
1096            _ => None,
1097        };
1098        let context = RequestContext {
1099            remote_address: Some(Address::new(Self::TRANSPORT_TYPE, self.address.clone())),
1100            peer_pubkey,
1101        };
1102        Ok(handler.handle_request(request, &context).await)
1103    }
1104
1105    fn is_server_running(&self) -> bool {
1106        self.running
1107    }
1108
1109    fn get_server_address(&self) -> Result<String> {
1110        if self.running {
1111            Ok(self.address.clone())
1112        } else {
1113            Err(SyncError::ServerNotRunning.into())
1114        }
1115    }
1116}
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121    use crate::{crdt::Doc, store::DocStore};
1122
1123    async fn write(db: &Database, key: &str, value: &str) -> Result<()> {
1124        let tx = db.new_transaction().await?;
1125        tx.get_store::<DocStore>("data")
1126            .await?
1127            .set_string(key, value)
1128            .await?;
1129        tx.commit().await?;
1130        Ok(())
1131    }
1132
1133    async fn read(db: &Database, key: &str) -> Result<String> {
1134        let tx = db.new_transaction().await?;
1135        tx.get_store::<DocStore>("data")
1136            .await?
1137            .get_string(key)
1138            .await
1139    }
1140
1141    /// Peer 0 creates a database (auth chosen by the test) and serves it; it
1142    /// writes `a`; peer 1 bootstraps and opens it. Returns the room id and both
1143    /// peers' open handles — the shared starting point for the tests below.
1144    async fn shared_room(net: &mut Cluster) -> Result<(ID, Database, Database)> {
1145        let key0 = net.peer(0).key_id().clone();
1146        let device0 = net.peer(0).instance().id();
1147        let mut settings = Doc::new();
1148        settings.set("name", "chat");
1149        let db0 = net
1150            .peer_mut(0)
1151            .user_mut()
1152            .create_database(settings, &key0)
1153            .await?;
1154        let room = db0.root_id().clone();
1155
1156        add_auth_keys(
1157            &db0,
1158            &[
1159                (&key0, AuthKey::active(Some("admin"), Permission::Admin(10))),
1160                (
1161                    &device0,
1162                    AuthKey::active(Some("device"), Permission::Admin(10)),
1163                ),
1164            ],
1165        )
1166        .await?;
1167        set_global_auth_key(&db0, AuthKey::active(None, Permission::Admin(10))).await?;
1168        net.peer_mut(0).serve(&room).await?;
1169        write(&db0, "a", "from-peer-0").await?;
1170
1171        net.bootstrap(0, 1, &room, Permission::Write(10)).await?;
1172        let db1 = net.peer_mut(1).user_mut().open_database(&room).await?;
1173        assert_eq!(
1174            read(&db1, "a").await?,
1175            "from-peer-0",
1176            "bootstrap carries the write"
1177        );
1178        Ok((room, db0, db1))
1179    }
1180
1181    /// Manual mode: peer 1 writes, an explicit `exchange` brings peer 0 up to
1182    /// date, and both converge. Sync is fully ordered by the test.
1183    #[tokio::test]
1184    async fn exchange_round_trips_and_converges() -> Result<()> {
1185        let mut net = Cluster::builder().peers(2).build().await?;
1186        let (room, db0, db1) = shared_room(&mut net).await?;
1187
1188        write(&db1, "b", "from-peer-1").await?;
1189        net.exchange(1, 0, &room).await?;
1190
1191        assert_eq!(read(&db0, "a").await?, "from-peer-0");
1192        assert_eq!(read(&db0, "b").await?, "from-peer-1");
1193        assert!(net.converged(&[0, 1], &room).await?);
1194        Ok(())
1195    }
1196
1197    /// Background mode: after `auto_sync`, commits propagate on their own — no
1198    /// `exchange` per write. `flush` is the only barrier the test needs.
1199    #[tokio::test]
1200    async fn auto_sync_propagates_on_commit() -> Result<()> {
1201        let mut net = Cluster::builder().peers(2).build().await?;
1202        let (room, db0, db1) = shared_room(&mut net).await?;
1203
1204        net.auto_sync(0, 1, &room).await?;
1205
1206        // Peer 0 commits; no exchange call — auto-sync carries it.
1207        write(&db0, "c", "auto-from-0").await?;
1208        net.flush(0).await?;
1209        assert_eq!(read(&db1, "c").await?, "auto-from-0");
1210
1211        // And the reverse direction.
1212        write(&db1, "d", "auto-from-1").await?;
1213        net.flush(1).await?;
1214        assert_eq!(read(&db0, "d").await?, "auto-from-1");
1215
1216        assert!(net.converged(&[0, 1], &room).await?);
1217        Ok(())
1218    }
1219
1220    /// [`SimTransport`] is a drop-in for [`HttpLoopback`]: the same bootstrap +
1221    /// `exchange` flow converges over the in-memory fabric, no sockets involved.
1222    #[tokio::test]
1223    async fn sim_transport_is_a_drop_in() -> Result<()> {
1224        let mut net = Cluster::builder()
1225            .peers(2)
1226            .transport(Arc::new(SimLoopback::new(SimNetwork::new())))
1227            .build()
1228            .await?;
1229        let (room, db0, db1) = shared_room(&mut net).await?;
1230
1231        write(&db1, "b", "from-peer-1").await?;
1232        net.exchange(1, 0, &room).await?;
1233
1234        assert_eq!(read(&db0, "a").await?, "from-peer-0");
1235        assert_eq!(read(&db0, "b").await?, "from-peer-1");
1236        assert!(net.converged(&[0, 1], &room).await?);
1237        Ok(())
1238    }
1239
1240    /// A partition drops traffic at the message level: with `auto_sync` wired,
1241    /// a commit on peer 0 cannot reach peer 1 while the link is cut, then
1242    /// redelivers from the still-pending queue once the link heals. This is what
1243    /// `HttpLoopback` can't do — there, withholding `exchange` is the only
1244    /// partition, and it can't model "wired but not delivering".
1245    #[tokio::test]
1246    async fn sim_partition_blocks_then_heal_delivers() -> Result<()> {
1247        let fabric = SimNetwork::new();
1248        let mut net = Cluster::builder()
1249            .peers(2)
1250            .transport(Arc::new(SimLoopback::new(fabric.clone())))
1251            .build()
1252            .await?;
1253        let (room, db0, db1) = shared_room(&mut net).await?;
1254        let addr0 = net.peer(0).address().clone();
1255        let addr1 = net.peer(1).address().clone();
1256
1257        net.auto_sync(0, 1, &room).await?;
1258
1259        // Cut the link, then commit on peer 0. The flush attempt cannot reach
1260        // peer 1 (a connection error to the sender), so the entry stays queued.
1261        fabric.partition(&addr0, &addr1);
1262        write(&db0, "c", "during-partition").await?;
1263        let _ = net.flush(0).await; // send fails across the cut; entry remains queued
1264
1265        assert!(
1266            read(&db1, "c").await.is_err(),
1267            "peer 1 must not see the write while partitioned"
1268        );
1269        assert!(
1270            !net.converged(&[0, 1], &room).await?,
1271            "peers must diverge under partition"
1272        );
1273
1274        // Heal and flush again: the queued entry now reaches peer 1.
1275        fabric.heal(&addr0, &addr1);
1276        net.flush(0).await?;
1277
1278        assert_eq!(read(&db1, "c").await?, "during-partition");
1279        assert!(net.converged(&[0, 1], &room).await?);
1280        Ok(())
1281    }
1282
1283    /// Manual delivery captures a flushed push instead of delivering it: after
1284    /// `flush` the sender believes it sent (optimistic Ack), but the receiver
1285    /// sees nothing until the test releases the message. `deliver_all` then
1286    /// drains the wire and the peers converge.
1287    #[tokio::test]
1288    async fn sim_manual_delivery_holds_then_releases() -> Result<()> {
1289        let fabric = SimNetwork::new();
1290        let mut net = Cluster::builder()
1291            .peers(2)
1292            .transport(Arc::new(SimLoopback::new(fabric.clone())))
1293            .build()
1294            .await?;
1295        let (room, db0, db1) = shared_room(&mut net).await?;
1296        net.auto_sync(0, 1, &room).await?;
1297
1298        // Capture pushes from here on, then commit + flush: the entry leaves the
1299        // sender but is held on the wire.
1300        fabric.set_manual_delivery(true);
1301        write(&db0, "c", "held").await?;
1302        net.flush(0).await?;
1303
1304        assert_eq!(fabric.pending().len(), 1, "the push is captured, not lost");
1305        assert!(
1306            read(&db1, "c").await.is_err(),
1307            "receiver sees nothing until the message is delivered"
1308        );
1309
1310        // Release the wire: the held push reaches peer 1 and they converge.
1311        assert_eq!(fabric.deliver_all().await, 1);
1312        assert_eq!(read(&db1, "c").await?, "held");
1313        assert!(net.converged(&[0, 1], &room).await?);
1314        Ok(())
1315    }
1316
1317    /// Two causally-ordered pushes delivered *back to front*. A receiver that
1318    /// gets a child before its parent must still converge once both arrive —
1319    /// this is the order-independence that an in-wired transport can't probe.
1320    #[tokio::test]
1321    async fn sim_reordered_delivery_still_converges() -> Result<()> {
1322        let fabric = SimNetwork::new();
1323        let mut net = Cluster::builder()
1324            .peers(2)
1325            .transport(Arc::new(SimLoopback::new(fabric.clone())))
1326            .build()
1327            .await?;
1328        let (room, db0, db1) = shared_room(&mut net).await?;
1329        net.auto_sync(0, 1, &room).await?;
1330        fabric.set_manual_delivery(true);
1331
1332        // Two separate flushes => two distinct in-flight messages; the second
1333        // entry's parent is the first, so they're causally ordered.
1334        write(&db0, "first", "1").await?;
1335        net.flush(0).await?;
1336        write(&db0, "second", "2").await?;
1337        net.flush(0).await?;
1338
1339        let pending = fabric.pending();
1340        assert_eq!(pending.len(), 2, "two independent pushes on the wire");
1341
1342        // Deliver child-before-parent: reverse send order.
1343        for &seq in pending.iter().rev() {
1344            assert!(fabric.deliver(seq).await, "message {seq} should deliver");
1345        }
1346
1347        assert_eq!(read(&db1, "first").await?, "1");
1348        assert_eq!(read(&db1, "second").await?, "2");
1349        assert!(net.converged(&[0, 1], &room).await?);
1350        Ok(())
1351    }
1352
1353    /// A duplicated push: the same message delivered twice. Sync must be
1354    /// idempotent — the second copy is a no-op, not a corruption or a crash.
1355    #[tokio::test]
1356    async fn sim_duplicate_delivery_is_idempotent() -> Result<()> {
1357        let fabric = SimNetwork::new();
1358        let mut net = Cluster::builder()
1359            .peers(2)
1360            .transport(Arc::new(SimLoopback::new(fabric.clone())))
1361            .build()
1362            .await?;
1363        let (room, db0, db1) = shared_room(&mut net).await?;
1364        net.auto_sync(0, 1, &room).await?;
1365        fabric.set_manual_delivery(true);
1366
1367        write(&db0, "c", "once").await?;
1368        net.flush(0).await?;
1369
1370        let seq = fabric.pending()[0];
1371        fabric.duplicate(seq).expect("message is queued");
1372        assert_eq!(fabric.pending().len(), 2, "original plus its duplicate");
1373
1374        // Both copies delivered; the second is a redelivery of the same entries.
1375        assert_eq!(fabric.deliver_all().await, 2);
1376
1377        assert_eq!(read(&db1, "c").await?, "once");
1378        assert!(net.converged(&[0, 1], &room).await?);
1379        Ok(())
1380    }
1381
1382    /// A dropped push is a genuine loss — the sender got its Ack and won't
1383    /// retry, so the receiver stays behind. A later reconciling `exchange`
1384    /// (tree-sync, which delivers inline) repairs the divergence: lossy delivery
1385    /// doesn't strand the cluster as long as some full sync eventually runs.
1386    #[tokio::test]
1387    async fn sim_dropped_push_is_recovered_by_resync() -> Result<()> {
1388        let fabric = SimNetwork::new();
1389        let mut net = Cluster::builder()
1390            .peers(2)
1391            .transport(Arc::new(SimLoopback::new(fabric.clone())))
1392            .build()
1393            .await?;
1394        let (room, db0, db1) = shared_room(&mut net).await?;
1395        net.auto_sync(0, 1, &room).await?;
1396        fabric.set_manual_delivery(true);
1397
1398        write(&db0, "c", "dropped").await?;
1399        net.flush(0).await?;
1400
1401        let seq = fabric.pending()[0];
1402        assert!(fabric.drop_message(seq), "the push is dropped on the wire");
1403        assert!(
1404            read(&db1, "c").await.is_err(),
1405            "a dropped push never reaches the receiver"
1406        );
1407        assert!(
1408            !net.converged(&[0, 1], &room).await?,
1409            "the cluster diverges after a drop"
1410        );
1411
1412        // A reconciling tree-sync pulls what the dropped push lost.
1413        fabric.set_manual_delivery(false);
1414        net.exchange(1, 0, &room).await?;
1415
1416        assert_eq!(read(&db1, "c").await?, "dropped");
1417        assert!(net.converged(&[0, 1], &room).await?);
1418        Ok(())
1419    }
1420}