feat: DM epoch fix, federation relay, and mDNS mesh discovery
- schema: createChannel returns wasNew :Bool to elect the MLS initiator unambiguously; prevents duplicate group creation on concurrent /dm calls - core: group helpers for epoch tracking and key-package lifecycle - server: federation subsystem — mTLS QUIC server-to-server relay with Cap'n Proto RPC; enqueue/batchEnqueue relay unknown recipients to their home domain via FederationClient - server: mDNS _quicproquo._udp.local. service announcement on startup - server: storage + sql_store — identity_exists, peek/ack, federation home-server lookup helpers - client: /mesh peers REPL command (mDNS discovery, feature = "mesh") - client: MeshDiscovery — background mDNS browse with ServiceDaemon - client: was_new=false path in cmd_dm waits for peer Welcome instead of creating a duplicate initiator group - p2p: fix ALPN from quicnprotochat/p2p/1 → quicproquo/p2p/1 - workspace: re-include quicproquo-p2p in members
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
// cargo_bin! only works for current package's binary; we spawn qpq-server from another package.
|
||||
#![allow(deprecated)]
|
||||
|
||||
use std::{path::PathBuf, process::Command, time::Duration};
|
||||
use std::{path::PathBuf, process::Command, sync::Mutex, time::Duration};
|
||||
|
||||
use assert_cmd::cargo::cargo_bin;
|
||||
use portpicker::pick_unused_port;
|
||||
@@ -17,11 +17,15 @@ fn ensure_rustls_provider() {
|
||||
|
||||
use quicproquo_client::{
|
||||
cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state,
|
||||
cmd_register_user, cmd_send, connect_node, enqueue, fetch_wait, init_auth,
|
||||
receive_pending_plaintexts, ClientAuth,
|
||||
cmd_register_user, cmd_send, connect_node, create_channel, enqueue, fetch_wait, init_auth,
|
||||
opaque_login, receive_pending_plaintexts, resolve_user, ClientAuth,
|
||||
};
|
||||
use quicproquo_core::IdentityKeypair;
|
||||
|
||||
/// Serialises all tests that call `init_auth` with a non-devtoken session to prevent
|
||||
/// the global `AUTH_CONTEXT` from being overwritten by concurrent tests.
|
||||
static AUTH_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
@@ -33,6 +37,13 @@ struct StoredStateCompat {
|
||||
group: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
struct ChildGuard(std::process::Child);
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) -> anyhow::Result<()> {
|
||||
let local = tokio::task::LocalSet::new();
|
||||
for _ in 0..30 {
|
||||
@@ -48,26 +59,17 @@ async fn wait_for_health(server: &str, ca_cert: &PathBuf, server_name: &str) ->
|
||||
anyhow::bail!("server health never became ready")
|
||||
}
|
||||
|
||||
/// Creator and joiner register; creator creates group and invites joiner; joiner joins;
|
||||
/// creator sends a message; assert joiner's mailbox receives it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
/// Spawns a server with the given extra args and returns (listen_addr, ca_cert_path, ChildGuard).
|
||||
fn spawn_server(base: &std::path::Path, extra_args: &[&str]) -> (String, PathBuf, ChildGuard) {
|
||||
let port = pick_unused_port().expect("free port");
|
||||
let listen = format!("127.0.0.1:{port}");
|
||||
let server = listen.clone();
|
||||
let ca_cert = base.join("server-cert.der");
|
||||
let tls_key = base.join("server-key.der");
|
||||
let data_dir = base.join("data");
|
||||
let auth_token = "devtoken";
|
||||
|
||||
// Spawn server binary.
|
||||
let server_bin = cargo_bin("qpq-server");
|
||||
let child = Command::new(server_bin)
|
||||
.arg("--listen")
|
||||
let mut cmd = Command::new(server_bin);
|
||||
cmd.arg("--listen")
|
||||
.arg(&listen)
|
||||
.arg("--data-dir")
|
||||
.arg(&data_dir)
|
||||
@@ -76,25 +78,30 @@ async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
|
||||
.arg("--tls-key")
|
||||
.arg(&tls_key)
|
||||
.arg("--auth-token")
|
||||
.arg(auth_token)
|
||||
.arg("--allow-insecure-auth")
|
||||
.spawn()
|
||||
.expect("spawn server");
|
||||
|
||||
// Ensure we always terminate the child.
|
||||
struct ChildGuard(std::process::Child);
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
.arg("devtoken")
|
||||
.arg("--allow-insecure-auth");
|
||||
for arg in extra_args {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
let child_guard = ChildGuard(child);
|
||||
let _ = child_guard;
|
||||
let child = cmd.spawn().expect("spawn server");
|
||||
(listen, ca_cert, ChildGuard(child))
|
||||
}
|
||||
|
||||
// ─── existing tests (fixed: add --sealed-sender so enqueue works with bearer token) ─────────────
|
||||
|
||||
/// Creator and joiner register; creator creates group and invites joiner; joiner joins;
|
||||
/// creator sends a message; assert joiner's mailbox receives it.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_happy_path_register_invite_join_send_recv() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
let auth_token = "devtoken";
|
||||
|
||||
let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]);
|
||||
|
||||
// Wait for server to be healthy and certs to be generated.
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
|
||||
// Set client auth context.
|
||||
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
@@ -179,37 +186,9 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
let port = pick_unused_port().expect("free port");
|
||||
let listen = format!("127.0.0.1:{port}");
|
||||
let server = listen.clone();
|
||||
let ca_cert = base.join("server-cert.der");
|
||||
let tls_key = base.join("server-key.der");
|
||||
let data_dir = base.join("data");
|
||||
let auth_token = "devtoken";
|
||||
|
||||
let server_bin = cargo_bin("qpq-server");
|
||||
let child = Command::new(server_bin)
|
||||
.arg("--listen")
|
||||
.arg(&listen)
|
||||
.arg("--data-dir")
|
||||
.arg(&data_dir)
|
||||
.arg("--tls-cert")
|
||||
.arg(&ca_cert)
|
||||
.arg("--tls-key")
|
||||
.arg(&tls_key)
|
||||
.arg("--auth-token")
|
||||
.arg(auth_token)
|
||||
.arg("--allow-insecure-auth")
|
||||
.spawn()
|
||||
.expect("spawn server");
|
||||
|
||||
struct ChildGuard(std::process::Child);
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
}
|
||||
let _child_guard = ChildGuard(child);
|
||||
let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
||||
@@ -388,46 +367,16 @@ async fn e2e_three_party_group_invite_join_send_recv() -> anyhow::Result<()> {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
let port = pick_unused_port().expect("free port");
|
||||
let listen = format!("127.0.0.1:{port}");
|
||||
let server = listen.clone();
|
||||
let ca_cert = base.join("server-cert.der");
|
||||
let tls_key = base.join("server-key.der");
|
||||
let data_dir = base.join("data");
|
||||
let auth_token = "devtoken";
|
||||
|
||||
// Spawn server binary.
|
||||
let server_bin = cargo_bin("qpq-server");
|
||||
let child = Command::new(server_bin)
|
||||
.arg("--listen")
|
||||
.arg(&listen)
|
||||
.arg("--data-dir")
|
||||
.arg(&data_dir)
|
||||
.arg("--tls-cert")
|
||||
.arg(&ca_cert)
|
||||
.arg("--tls-key")
|
||||
.arg(&tls_key)
|
||||
.arg("--auth-token")
|
||||
.arg(auth_token)
|
||||
.arg("--allow-insecure-auth")
|
||||
.spawn()
|
||||
.expect("spawn server");
|
||||
|
||||
struct ChildGuard(std::process::Child);
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
}
|
||||
let child_guard = ChildGuard(child);
|
||||
let _ = child_guard;
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
|
||||
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
let state_path = base.join("user.bin");
|
||||
@@ -482,7 +431,6 @@ async fn e2e_login_rejects_mismatched_identity() -> anyhow::Result<()> {
|
||||
match result {
|
||||
Ok(_) => anyhow::bail!("login unexpectedly succeeded with mismatched identity"),
|
||||
Err(e) => {
|
||||
// Show the full error chain so we can match the server's E016 response.
|
||||
let msg = format!("{e:#}");
|
||||
anyhow::ensure!(
|
||||
msg.contains("identity") || msg.contains("E016"),
|
||||
@@ -501,41 +449,11 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> {
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
let port = pick_unused_port().expect("free port");
|
||||
let listen = format!("127.0.0.1:{port}");
|
||||
let server = listen.clone();
|
||||
let ca_cert = base.join("server-cert.der");
|
||||
let tls_key = base.join("server-key.der");
|
||||
let data_dir = base.join("data");
|
||||
let auth_token = "devtoken";
|
||||
|
||||
let server_bin = cargo_bin("qpq-server");
|
||||
let child = Command::new(server_bin)
|
||||
.arg("--listen")
|
||||
.arg(&listen)
|
||||
.arg("--data-dir")
|
||||
.arg(&data_dir)
|
||||
.arg("--tls-cert")
|
||||
.arg(&ca_cert)
|
||||
.arg("--tls-key")
|
||||
.arg(&tls_key)
|
||||
.arg("--auth-token")
|
||||
.arg(auth_token)
|
||||
.arg("--allow-insecure-auth")
|
||||
.arg("--sealed-sender")
|
||||
.spawn()
|
||||
.expect("spawn server");
|
||||
|
||||
struct ChildGuard(std::process::Child);
|
||||
impl Drop for ChildGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
}
|
||||
let _child_guard = ChildGuard(child);
|
||||
let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts(auth_token.to_string(), None));
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
let state_path = base.join("recipient.bin");
|
||||
@@ -595,3 +513,425 @@ async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ─── new tests: was_new semantics, resolve_user, DM MLS flow ─────────────────────────────────
|
||||
|
||||
/// `create_channel` returns `was_new=true` for the first caller and `was_new=false` for the
|
||||
/// second, and both callers receive the same stable `channel_id` regardless of argument order.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_create_channel_was_new_semantics() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
// Holds AUTH_CONTEXT for the duration of this test.
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
// No --sealed-sender: create_channel requires identity-bound session.
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
|
||||
// Register identity states (uses devtoken / allow-insecure for upload_key_package).
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
let alice_state = base.join("alice.bin");
|
||||
let bob_state = base.join("bob.bin");
|
||||
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let alice_seed: [u8; 32] = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||
let bob_seed: [u8; 32] = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&bob_state)?)?.identity_seed;
|
||||
let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec();
|
||||
let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec();
|
||||
let alice_pk_hex = hex_encode(&alice_pk);
|
||||
let bob_pk_hex = hex_encode(&bob_pk);
|
||||
|
||||
// OPAQUE register (unauthenticated — no AUTH_CONTEXT needed).
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex)))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex)))
|
||||
.await?;
|
||||
|
||||
// Alice OPAQUE login → identity-bound session.
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
let session_alice = local
|
||||
.run_until(opaque_login(&client, "alice", "pass", &alice_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_alice, None));
|
||||
|
||||
let (ch_alice, was_new_alice) = local
|
||||
.run_until(create_channel(&client, &bob_pk))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(was_new_alice, "Alice's create_channel must return was_new=true");
|
||||
anyhow::ensure!(ch_alice.len() == 16, "channel_id must be 16 bytes");
|
||||
|
||||
// Bob OPAQUE login → identity-bound session.
|
||||
let session_bob = local
|
||||
.run_until(opaque_login(&client, "bob", "pass", &bob_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_bob, None));
|
||||
|
||||
let (ch_bob, was_new_bob) = local
|
||||
.run_until(create_channel(&client, &alice_pk))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(!was_new_bob, "Bob's create_channel must return was_new=false (channel already exists)");
|
||||
anyhow::ensure!(
|
||||
ch_alice == ch_bob,
|
||||
"Both callers must receive the same channel_id (got alice={} bob={})",
|
||||
hex_encode(&ch_alice),
|
||||
hex_encode(&ch_bob)
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `resolve_user` returns the identity key when the user registered WITH one,
|
||||
/// and returns `None` when the user registered WITHOUT an identity key.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_resolve_user_requires_identity_key_binding() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
// Generate Alice's identity (bound) and Bob's identity (unbound).
|
||||
let alice_state = base.join("alice.bin");
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let alice_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||
let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec();
|
||||
let alice_pk_hex = hex_encode(&alice_pk);
|
||||
|
||||
// Alice registers WITH identity key.
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex)))
|
||||
.await?;
|
||||
|
||||
// Bob registers WITHOUT identity key.
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", None))
|
||||
.await?;
|
||||
|
||||
// resolve_user needs a valid auth context (devtoken is sufficient — just needs bearer token).
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
|
||||
let alice_resolved = local
|
||||
.run_until(resolve_user(&client, "alice"))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
alice_resolved == Some(alice_pk.clone()),
|
||||
"resolve_user('alice') must return alice's identity key, got {:?}",
|
||||
alice_resolved.as_ref().map(|k| hex_encode(k))
|
||||
);
|
||||
|
||||
let bob_resolved = local
|
||||
.run_until(resolve_user(&client, "bob"))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
bob_resolved.is_none(),
|
||||
"resolve_user('bob') must return None (no identity key bound), got {:?}",
|
||||
bob_resolved.as_ref().map(|k| hex_encode(k))
|
||||
);
|
||||
|
||||
let ghost_resolved = local
|
||||
.run_until(resolve_user(&client, "nonexistent"))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
ghost_resolved.is_none(),
|
||||
"resolve_user('nonexistent') must return None"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Both Alice and Bob call `/dm` on each other (simultaneous DM initiation).
|
||||
/// Only the first caller (was_new=true) creates the MLS group and sends a Welcome.
|
||||
/// The second caller (was_new=false) joins via the Welcome.
|
||||
/// After joining, Alice sends a message and Bob decrypts it with no epoch error.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_bidirectional_dm_mls_no_epoch_conflict() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
// No --sealed-sender: tests the production path where enqueue requires identity session.
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
|
||||
// Register state files (uploads KeyPackages + hybrid keys) using devtoken.
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
let alice_state = base.join("alice.bin");
|
||||
let bob_state = base.join("bob.bin");
|
||||
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let alice_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||
let bob_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&bob_state)?)?.identity_seed;
|
||||
let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec();
|
||||
let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec();
|
||||
let alice_pk_hex = hex_encode(&alice_pk);
|
||||
let bob_pk_hex = hex_encode(&bob_pk);
|
||||
|
||||
// OPAQUE register both users.
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex)))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex)))
|
||||
.await?;
|
||||
|
||||
// Alice logs in and calls create_channel → must get was_new=true.
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
let session_alice = local
|
||||
.run_until(opaque_login(&client, "alice", "pass", &alice_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_alice.clone(), None));
|
||||
|
||||
let (channel_id, was_new_alice) = local
|
||||
.run_until(create_channel(&client, &bob_pk))
|
||||
.await?;
|
||||
anyhow::ensure!(was_new_alice, "Alice must get was_new=true");
|
||||
|
||||
// Alice creates MLS group (channel_id as group name) and invites Bob.
|
||||
local
|
||||
.run_until(cmd_create_group(&alice_state, &server, &hex_encode(&channel_id), None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None))
|
||||
.await?;
|
||||
|
||||
// Bob logs in and calls create_channel → must get was_new=false with same channel_id.
|
||||
let session_bob = local
|
||||
.run_until(opaque_login(&client, "bob", "pass", &bob_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_bob.clone(), None));
|
||||
|
||||
let (channel_id_bob, was_new_bob) = local
|
||||
.run_until(create_channel(&client, &alice_pk))
|
||||
.await?;
|
||||
anyhow::ensure!(!was_new_bob, "Bob must get was_new=false (Alice created first)");
|
||||
anyhow::ensure!(
|
||||
channel_id == channel_id_bob,
|
||||
"Both sides must see the same channel_id"
|
||||
);
|
||||
|
||||
// Bob joins via Welcome that Alice sent (was_new=false path: no group creation, just join).
|
||||
local
|
||||
.run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
// Alice sends "hello" to Bob.
|
||||
init_auth(ClientAuth::from_raw(session_alice, None));
|
||||
local
|
||||
.run_until(cmd_send(
|
||||
&alice_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
Some(&bob_pk_hex),
|
||||
false,
|
||||
"hello from alice",
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Bob receives and decrypts — no epoch conflict.
|
||||
init_auth(ClientAuth::from_raw(session_bob, None));
|
||||
let plaintexts = local
|
||||
.run_until(receive_pending_plaintexts(
|
||||
&bob_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
1000,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
anyhow::ensure!(
|
||||
plaintexts.iter().any(|p| p.as_slice() == b"hello from alice"),
|
||||
"Bob must decrypt Alice's message without epoch error; got {:?}",
|
||||
plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send 10 messages alternating Alice→Bob and Bob→Alice through an MLS DM channel.
|
||||
/// All messages must decrypt successfully, proving epoch stays in sync.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn e2e_dm_multi_message_epoch_synchronized() -> anyhow::Result<()> {
|
||||
ensure_rustls_provider();
|
||||
let _auth = AUTH_LOCK.lock().unwrap();
|
||||
|
||||
let temp = TempDir::new()?;
|
||||
let base = temp.path();
|
||||
|
||||
let (server, ca_cert, _child) = spawn_server(base, &[]);
|
||||
|
||||
wait_for_health(&server, &ca_cert, "localhost").await?;
|
||||
init_auth(ClientAuth::from_parts("devtoken".to_string(), None));
|
||||
|
||||
let local = tokio::task::LocalSet::new();
|
||||
|
||||
let alice_state = base.join("alice.bin");
|
||||
let bob_state = base.join("bob.bin");
|
||||
|
||||
local
|
||||
.run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_state(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
let alice_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&alice_state)?)?.identity_seed;
|
||||
let bob_seed = bincode::deserialize::<StoredStateCompat>(&std::fs::read(&bob_state)?)?.identity_seed;
|
||||
let alice_pk = IdentityKeypair::from_seed(alice_seed).public_key_bytes().to_vec();
|
||||
let bob_pk = IdentityKeypair::from_seed(bob_seed).public_key_bytes().to_vec();
|
||||
let alice_pk_hex = hex_encode(&alice_pk);
|
||||
let bob_pk_hex = hex_encode(&bob_pk);
|
||||
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "alice", "pass", Some(&alice_pk_hex)))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_register_user(&server, &ca_cert, "localhost", "bob", "pass", Some(&bob_pk_hex)))
|
||||
.await?;
|
||||
|
||||
let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?;
|
||||
|
||||
// Alice creates the DM channel and invites Bob.
|
||||
let session_alice = local
|
||||
.run_until(opaque_login(&client, "alice", "pass", &alice_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_alice.clone(), None));
|
||||
|
||||
let (channel_id, was_new) = local
|
||||
.run_until(create_channel(&client, &bob_pk))
|
||||
.await?;
|
||||
anyhow::ensure!(was_new, "first create_channel must be was_new=true");
|
||||
|
||||
local
|
||||
.run_until(cmd_create_group(&alice_state, &server, &hex_encode(&channel_id), None))
|
||||
.await?;
|
||||
local
|
||||
.run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None))
|
||||
.await?;
|
||||
|
||||
// Bob joins.
|
||||
let session_bob = local
|
||||
.run_until(opaque_login(&client, "bob", "pass", &bob_pk))
|
||||
.await?;
|
||||
init_auth(ClientAuth::from_raw(session_bob.clone(), None));
|
||||
local
|
||||
.run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None))
|
||||
.await?;
|
||||
|
||||
// 10 messages: Alice→Bob on even, Bob→Alice on odd.
|
||||
for i in 0u32..10 {
|
||||
let msg = format!("msg_{i}");
|
||||
if i % 2 == 0 {
|
||||
// Alice sends to Bob.
|
||||
init_auth(ClientAuth::from_raw(session_alice.clone(), None));
|
||||
local
|
||||
.run_until(cmd_send(
|
||||
&alice_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
Some(&bob_pk_hex),
|
||||
false,
|
||||
&msg,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Bob receives.
|
||||
init_auth(ClientAuth::from_raw(session_bob.clone(), None));
|
||||
let plaintexts = local
|
||||
.run_until(receive_pending_plaintexts(
|
||||
&bob_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
1000,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
plaintexts.iter().any(|p| p.as_slice() == msg.as_bytes()),
|
||||
"Bob did not receive '{msg}' at iteration {i}; got {:?}",
|
||||
plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
} else {
|
||||
// Bob sends to Alice.
|
||||
init_auth(ClientAuth::from_raw(session_bob.clone(), None));
|
||||
local
|
||||
.run_until(cmd_send(
|
||||
&bob_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
Some(&alice_pk_hex),
|
||||
false,
|
||||
&msg,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Alice receives.
|
||||
init_auth(ClientAuth::from_raw(session_alice.clone(), None));
|
||||
let plaintexts = local
|
||||
.run_until(receive_pending_plaintexts(
|
||||
&alice_state,
|
||||
&server,
|
||||
&ca_cert,
|
||||
"localhost",
|
||||
1000,
|
||||
None,
|
||||
))
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
plaintexts.iter().any(|p| p.as_slice() == msg.as_bytes()),
|
||||
"Alice did not receive '{msg}' at iteration {i}; got {:?}",
|
||||
plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user