CI / check (push) Successful in 2m59s
Chat-hardening plan Phase 2 — only current authenticated room members can create chat UI work, impersonation via the wire name is structurally closed, and no member can monopolize the event channel: - core: new ChatRoster (bounded id -> sanitized-name map, shared) replaces the event task's bare HashSet; upserted on PeerJoined/PeerUpdated, removed on graceful PeerLeft AND terminal grace-expiry eviction (both timer paths). Non-roster chat is dropped before attachment handling; the rendered author label is the roster-bound name — the sender-claimed wire name is never read. - gossip: ChatIngressGate after verify_gossip, before any sanitize work or event send: early known-author gate (live + mid-reconnect peers), exact- replay suppression keyed on the deterministic Ed25519 signature (1024-entry cap + freshness-window TTL, zero new deps vs the plan's BLAKE3 option), then per-author (8 burst, 1/s) and room-wide (32 burst, 8/s) token buckets. Replays are detected before tokens are consumed; a room-bucket reject refunds the author token; rejection logging is squelched per author. - The inner Chat.ts is now ignored entirely; RoomEvent carries the signed envelope timestamp. 550 lib tests (+18), reconnect_eviction +1 (grace keeps chat authority, terminal eviction revokes it), clippy --all-targets -D warnings clean. Tests-green-only: the plan's two-machine field-test section remains open. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
240 lines
9.3 KiB
Rust
240 lines
9.3 KiB
Rust
//! Regression tests for the reconnect-eviction logic in `core`'s conn-event
|
|
//! handler. The bug these guard: after a peer reconnects, a *second* sustained
|
|
//! outage never evicted it, because the grace timer was armed only by the gossip
|
|
//! `PeerConnectionLost` path — which goes silent once gossip's neighbor state is
|
|
//! stale — while the transport keeps reliably reporting the drop. The fix arms the
|
|
//! grace timer from the transport `ConnEvent::Connecting` path too. These tests
|
|
//! drive `ConnEventHandler` directly with a short grace so the second-outage
|
|
//! eviction (and the cases that must NOT evict) are verified without a live link.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use tokio::sync::mpsc;
|
|
|
|
use iroh::endpoint::presets;
|
|
use iroh::{Endpoint, EndpointId, RelayMode, SecretKey};
|
|
|
|
use peerspeak::core::ConnEventHandler;
|
|
use peerspeak::core::messages::UiEvent;
|
|
use peerspeak::network::ConnEvent;
|
|
use peerspeak::network::iroh_impl::IrohTransport;
|
|
|
|
/// A real (but unconnected) transport. The handler's eviction path calls
|
|
/// `disconnect_peer`, which is a safe no-op for a peer we never linked to.
|
|
async fn make_transport() -> Arc<IrohTransport> {
|
|
let endpoint = Endpoint::builder(presets::Minimal)
|
|
.secret_key(SecretKey::generate())
|
|
.relay_mode(RelayMode::Disabled)
|
|
.bind()
|
|
.await
|
|
.expect("bind endpoint");
|
|
Arc::new(IrohTransport::new(endpoint))
|
|
}
|
|
|
|
/// A peer identity with no live endpoint — all we need to key the handler's maps.
|
|
fn fake_peer() -> EndpointId {
|
|
SecretKey::generate().public()
|
|
}
|
|
|
|
fn make_handler(
|
|
ui_tx: mpsc::Sender<UiEvent>,
|
|
transport: Arc<IrohTransport>,
|
|
grace: Duration,
|
|
) -> ConnEventHandler {
|
|
let grace_timers = Arc::new(std::sync::Mutex::new(HashMap::new()));
|
|
let seen_connected = Arc::new(std::sync::Mutex::new(HashSet::new()));
|
|
let jitter = Arc::new(tokio::sync::Mutex::new(HashMap::new()));
|
|
ConnEventHandler::new(ui_tx, grace_timers, seen_connected, transport, jitter).with_grace(grace)
|
|
}
|
|
|
|
/// Drain UI events until a `PeerConnectionFailed` for `peer` (eviction) arrives,
|
|
/// or `within` elapses. Returns whether the eviction fired.
|
|
async fn evicted_within(
|
|
ui_rx: &mut mpsc::Receiver<UiEvent>,
|
|
peer: EndpointId,
|
|
within: Duration,
|
|
) -> bool {
|
|
let deadline = tokio::time::Instant::now() + within;
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, ui_rx.recv()).await {
|
|
Ok(Some(UiEvent::PeerConnectionFailed { id })) if id == peer => return true,
|
|
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected / PeerLeft
|
|
Ok(None) => return false, // channel closed
|
|
Err(_) => return false, // timed out — no eviction
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Drain UI events until a `PeerLeft` for `peer` arrives, or `within` elapses.
|
|
/// Returns whether the leave fired.
|
|
async fn left_within(
|
|
ui_rx: &mut mpsc::Receiver<UiEvent>,
|
|
peer: EndpointId,
|
|
within: Duration,
|
|
) -> bool {
|
|
let deadline = tokio::time::Instant::now() + within;
|
|
loop {
|
|
match tokio::time::timeout_at(deadline, ui_rx.recv()).await {
|
|
Ok(Some(UiEvent::PeerLeft { id })) if id == peer => return true,
|
|
Ok(Some(_)) => continue, // ignore PeerConnecting / PeerConnected
|
|
Ok(None) => return false, // channel closed
|
|
Err(_) => return false, // timed out — no leave
|
|
}
|
|
}
|
|
}
|
|
|
|
const GRACE: Duration = Duration::from_millis(200);
|
|
|
|
/// The regression: connect, drop, reconnect, then drop again — the second outage
|
|
/// must still arm a grace timer and evict. (Before the fix, only the first did.)
|
|
#[tokio::test]
|
|
async fn second_outage_after_reconnect_still_evicts() {
|
|
let (ui_tx, mut ui_rx) = mpsc::channel(100);
|
|
let h = make_handler(ui_tx, make_transport().await, GRACE);
|
|
let peer = fake_peer();
|
|
|
|
h.handle(ConnEvent::Connected(peer)).await; // initial link up
|
|
h.handle(ConnEvent::Connecting(peer)).await; // first drop -> arms
|
|
h.handle(ConnEvent::Connected(peer)).await; // reconnects -> cancels
|
|
h.handle(ConnEvent::Connecting(peer)).await; // SECOND drop -> must re-arm
|
|
|
|
assert!(
|
|
evicted_within(&mut ui_rx, peer, Duration::from_secs(2)).await,
|
|
"second outage after a reconnect must evict the peer"
|
|
);
|
|
}
|
|
|
|
/// A peer that recovers within the grace window must NOT be evicted.
|
|
#[tokio::test]
|
|
async fn reconnect_within_grace_is_not_evicted() {
|
|
let (ui_tx, mut ui_rx) = mpsc::channel(100);
|
|
let h = make_handler(ui_tx, make_transport().await, GRACE);
|
|
let peer = fake_peer();
|
|
|
|
h.handle(ConnEvent::Connected(peer)).await;
|
|
h.handle(ConnEvent::Connecting(peer)).await; // drop -> arms
|
|
h.handle(ConnEvent::Connected(peer)).await; // recovers -> cancels
|
|
|
|
assert!(
|
|
!evicted_within(&mut ui_rx, peer, GRACE * 3).await,
|
|
"a peer that reconnects within the grace window must not be evicted"
|
|
);
|
|
}
|
|
|
|
/// A first-ever dial (never connected) must not be handed an eviction timer.
|
|
#[tokio::test]
|
|
async fn first_ever_dial_is_not_evicted() {
|
|
let (ui_tx, mut ui_rx) = mpsc::channel(100);
|
|
let h = make_handler(ui_tx, make_transport().await, GRACE);
|
|
let peer = fake_peer();
|
|
|
|
// Connecting with no prior Connected: the peer isn't in `seen_connected`.
|
|
h.handle(ConnEvent::Connecting(peer)).await;
|
|
|
|
assert!(
|
|
!evicted_within(&mut ui_rx, peer, GRACE * 3).await,
|
|
"a first-ever dial must not arm an eviction timer"
|
|
);
|
|
}
|
|
|
|
/// A graceful leave must scrub `seen_connected`, so if that identity later rejoins,
|
|
/// its initial dial isn't mistaken for a reconnect and given an eviction clock.
|
|
#[tokio::test]
|
|
async fn graceful_leave_lets_a_later_rejoin_dial_cleanly() {
|
|
let (ui_tx, mut ui_rx) = mpsc::channel(100);
|
|
let h = make_handler(ui_tx, make_transport().await, GRACE);
|
|
let peer = fake_peer();
|
|
|
|
h.handle(ConnEvent::Connected(peer)).await; // linked once
|
|
h.handle(ConnEvent::Left(peer)).await; // graceful leave -> scrub seen
|
|
h.handle(ConnEvent::Connecting(peer)).await; // later rejoin's initial dial
|
|
|
|
assert!(
|
|
!evicted_within(&mut ui_rx, peer, GRACE * 3).await,
|
|
"a rejoin's initial dial after a graceful leave must not arm eviction"
|
|
);
|
|
}
|
|
|
|
/// A graceful leave evicts *immediately* — it emits `PeerLeft` right away rather
|
|
/// than leaving the peer "reconnecting" until the grace timer (or the slow gossip
|
|
/// Leave) catches up.
|
|
#[tokio::test]
|
|
async fn graceful_leave_evicts_immediately() {
|
|
let (ui_tx, mut ui_rx) = mpsc::channel(100);
|
|
let h = make_handler(ui_tx, make_transport().await, GRACE);
|
|
let peer = fake_peer();
|
|
|
|
h.handle(ConnEvent::Connected(peer)).await;
|
|
h.handle(ConnEvent::Left(peer)).await;
|
|
|
|
// Well inside the grace window: the leave must not be gated on the timer.
|
|
assert!(
|
|
left_within(&mut ui_rx, peer, GRACE / 2).await,
|
|
"a graceful leave must emit PeerLeft immediately, not wait for the grace timer"
|
|
);
|
|
}
|
|
|
|
/// After a peer is evicted by the grace timer, its identity is scrubbed from
|
|
/// `seen_connected`, so a later rejoin's initial dial is a fresh first-dial and
|
|
/// must not be mistaken for a reconnect (which would arm a new eviction clock).
|
|
#[tokio::test]
|
|
async fn rejoin_after_grace_eviction_dials_cleanly() {
|
|
let (ui_tx, mut ui_rx) = mpsc::channel(100);
|
|
let h = make_handler(ui_tx, make_transport().await, GRACE);
|
|
let peer = fake_peer();
|
|
|
|
// Connect, drop, and let the grace timer fire (evicts + scrubs seen_connected).
|
|
h.handle(ConnEvent::Connected(peer)).await;
|
|
h.handle(ConnEvent::Connecting(peer)).await;
|
|
assert!(
|
|
evicted_within(&mut ui_rx, peer, GRACE * 4).await,
|
|
"the first outage should evict once the grace window elapses"
|
|
);
|
|
|
|
// A later rejoin's initial dial: scrubbed on eviction, so this is a first-ever
|
|
// dial again and must not arm a new eviction timer.
|
|
h.handle(ConnEvent::Connecting(peer)).await;
|
|
assert!(
|
|
!evicted_within(&mut ui_rx, peer, GRACE * 3).await,
|
|
"an initial dial after a grace eviction must not be treated as a reconnect"
|
|
);
|
|
}
|
|
|
|
/// Chat-hardening Phase 2: a peer mid-reconnect-grace keeps its roster-bound
|
|
/// chat name (its chat stays admitted), but a TERMINAL grace-expiry eviction
|
|
/// revokes it — after that, only a fresh authenticated Announce (PeerJoined)
|
|
/// restores chat authority.
|
|
#[tokio::test]
|
|
async fn grace_eviction_revokes_chat_roster_entry() {
|
|
use peerspeak::core::chatroster::ChatRoster;
|
|
|
|
let (ui_tx, mut ui_rx) = mpsc::channel(100);
|
|
let roster = ChatRoster::default();
|
|
let h = make_handler(ui_tx, make_transport().await, GRACE).with_chat_roster(roster.clone());
|
|
let peer = fake_peer();
|
|
roster.upsert(peer, "Victim");
|
|
|
|
// Link up, then drop: DURING the grace window the peer is still a member —
|
|
// its chat must keep rendering under its roster name.
|
|
h.handle(ConnEvent::Connected(peer)).await;
|
|
h.handle(ConnEvent::Connecting(peer)).await;
|
|
assert_eq!(
|
|
roster.name_of(&peer),
|
|
Some("Victim".to_string()),
|
|
"reconnect grace must NOT revoke chat authority"
|
|
);
|
|
|
|
// Once the grace expires and the eviction fires, chat authority goes too.
|
|
assert!(
|
|
evicted_within(&mut ui_rx, peer, GRACE * 4).await,
|
|
"the outage should evict once the grace window elapses"
|
|
);
|
|
assert_eq!(
|
|
roster.name_of(&peer),
|
|
None,
|
|
"terminal eviction must revoke the roster-bound chat name"
|
|
);
|
|
}
|