// cargo_bin! only works for current package's binary; we spawn qpq-server from another package. #![allow(deprecated)] #![allow(clippy::unwrap_used)] #![allow(clippy::await_holding_lock)] // AUTH_LOCK intentionally held across await to serialize tests use std::{path::PathBuf, process::Command, sync::Mutex, time::Duration}; use assert_cmd::cargo::cargo_bin; use portpicker::pick_unused_port; use rand::RngCore; use tempfile::TempDir; use tokio::time::sleep; // Required by rustls 0.23 when QUIC/TLS is used from this process (e.g. client in test). fn ensure_rustls_provider() { let _ = rustls::crypto::ring::default_provider().install_default(); } use sha2::{Sha256, Digest}; use quicproquo_client::{ cmd_create_group, cmd_invite, cmd_join, cmd_login, cmd_ping, cmd_register_state, 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::{GroupMember, HybridKeypair, IdentityKeypair, ReceivedMessage}; /// 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() } #[derive(serde::Deserialize)] struct StoredStateCompat { identity_seed: [u8; 32], #[allow(dead_code)] group: Option>, } 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: &std::path::Path, server_name: &str) -> anyhow::Result<()> { let local = tokio::task::LocalSet::new(); for _ in 0..30 { if local .run_until(cmd_ping(server, ca_cert, server_name)) .await .is_ok() { return Ok(()); } sleep(Duration::from_millis(200)).await; } anyhow::bail!("server health never became ready") } /// 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 ca_cert = base.join("server-cert.der"); let tls_key = base.join("server-key.der"); let data_dir = base.join("data"); let server_bin = cargo_bin("qpq-server"); let mut cmd = Command::new(server_bin); cmd.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("devtoken") .arg("--allow-insecure-auth"); for arg in extra_args { cmd.arg(arg); } 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_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); let local = tokio::task::LocalSet::new(); let creator_state = base.join("creator.bin"); let joiner_state = base.join("joiner.bin"); local .run_until(cmd_register_state( &creator_state, &server, &ca_cert, "localhost", None, )) .await?; local .run_until(cmd_register_state( &joiner_state, &server, &ca_cert, "localhost", None, )) .await?; local .run_until(cmd_create_group(&creator_state, &server, "test-group", None)) .await?; let joiner_bytes = std::fs::read(&joiner_state)?; let joiner_state_compat: StoredStateCompat = bincode::deserialize(&joiner_bytes)?; let joiner_identity = IdentityKeypair::from_seed(joiner_state_compat.identity_seed); let joiner_pk_hex = hex_encode(&joiner_identity.public_key_bytes()); local .run_until(cmd_invite( &creator_state, &server, &ca_cert, "localhost", &joiner_pk_hex, None, )) .await?; local .run_until(cmd_join(&joiner_state, &server, &ca_cert, "localhost", None)) .await?; local .run_until(cmd_send( &creator_state, &server, &ca_cert, "localhost", Some(&joiner_pk_hex), false, "hello", None, )) .await?; local .run_until(async { let client = connect_node(&server, &ca_cert, "localhost").await?; let payloads = fetch_wait(&client, &joiner_identity.public_key_bytes(), 1000).await?; anyhow::ensure!(!payloads.is_empty(), "no payloads delivered to joiner"); Ok::<(), anyhow::Error>(()) }) .await?; Ok(()) } /// Three-party group: A creates group, invites B then C; B and C join; A sends, B and C receive; /// B sends, A and C receive. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_three_party_group_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_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); let local = tokio::task::LocalSet::new(); let creator_state = base.join("creator.bin"); let b_state = base.join("b.bin"); let c_state = base.join("c.bin"); local .run_until(cmd_register_state( &creator_state, &server, &ca_cert, "localhost", None, )) .await?; local .run_until(cmd_register_state( &b_state, &server, &ca_cert, "localhost", None, )) .await?; local .run_until(cmd_register_state( &c_state, &server, &ca_cert, "localhost", None, )) .await?; let b_bytes = std::fs::read(&b_state)?; let b_compat: StoredStateCompat = bincode::deserialize(&b_bytes)?; let b_pk_hex = hex_encode(&IdentityKeypair::from_seed(b_compat.identity_seed).public_key_bytes()); let c_bytes = std::fs::read(&c_state)?; let c_compat: StoredStateCompat = bincode::deserialize(&c_bytes)?; let c_pk_hex = hex_encode(&IdentityKeypair::from_seed(c_compat.identity_seed).public_key_bytes()); local .run_until(cmd_create_group(&creator_state, &server, "test-group", None)) .await?; local .run_until(cmd_invite( &creator_state, &server, &ca_cert, "localhost", &b_pk_hex, None, )) .await?; local .run_until(cmd_invite( &creator_state, &server, &ca_cert, "localhost", &c_pk_hex, None, )) .await?; local .run_until(cmd_join(&b_state, &server, &ca_cert, "localhost", None)) .await?; local .run_until(cmd_join(&c_state, &server, &ca_cert, "localhost", None)) .await?; local .run_until(cmd_send( &creator_state, &server, &ca_cert, "localhost", None, true, "hello", None, )) .await?; sleep(Duration::from_millis(150)).await; let b_plaintexts = local .run_until(receive_pending_plaintexts( &b_state, &server, &ca_cert, "localhost", 1500, None, )) .await?; let c_plaintexts = local .run_until(receive_pending_plaintexts( &c_state, &server, &ca_cert, "localhost", 1500, None, )) .await?; anyhow::ensure!( b_plaintexts.iter().any(|p| p.as_slice() == b"hello"), "B did not receive 'hello', got {:?}", b_plaintexts ); anyhow::ensure!( c_plaintexts.iter().any(|p| p.as_slice() == b"hello"), "C did not receive 'hello', got {:?}", c_plaintexts ); local .run_until(cmd_send( &b_state, &server, &ca_cert, "localhost", None, true, "hi", None, )) .await?; sleep(Duration::from_millis(200)).await; let a_plaintexts = local .run_until(receive_pending_plaintexts( &creator_state, &server, &ca_cert, "localhost", 1500, None, )) .await?; let c_plaintexts2 = local .run_until(receive_pending_plaintexts( &c_state, &server, &ca_cert, "localhost", 1500, None, )) .await?; anyhow::ensure!( a_plaintexts.iter().any(|p| p.as_slice() == b"hi"), "A did not receive 'hi', got {:?}", a_plaintexts ); anyhow::ensure!( c_plaintexts2.iter().any(|p| p.as_slice() == b"hi"), "C did not receive 'hi', got {:?}", c_plaintexts2 ); Ok(()) } /// Login should refuse if the presented identity key does not match the registered key. #[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 (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 state_path = base.join("user.bin"); // Register and persist state (includes identity key binding). local .run_until(cmd_register_state( &state_path, &server, &ca_cert, "localhost", None, )) .await?; // Register the user with the bound identity so login can enforce mismatches. let state_bytes = std::fs::read(&state_path)?; let stored_state: StoredStateCompat = bincode::deserialize(&state_bytes)?; let identity_hex = hex::encode( IdentityKeypair::from_seed(stored_state.identity_seed).public_key_bytes(), ); local .run_until(cmd_register_user( &server, &ca_cert, "localhost", "user1", "pass", Some(&identity_hex), )) .await?; // Craft an unrelated identity key and attempt login with it. let mut bogus_identity = [0u8; 32]; rand::thread_rng().fill_bytes(&mut bogus_identity); let bogus_hex = hex::encode(bogus_identity); let result = local .run_until(cmd_login( &server, &ca_cert, "localhost", "user1", "pass", Some(&bogus_hex), None, None, )) .await; match result { Ok(_) => anyhow::bail!("login unexpectedly succeeded with mismatched identity"), Err(e) => { let msg = format!("{e:#}"); anyhow::ensure!( msg.contains("identity") || msg.contains("E016"), "login failed but not for identity mismatch: {msg}" ); } } Ok(()) } /// Sealed Sender: enqueue with valid token (no identity binding) succeeds; recipient can fetch. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_sealed_sender_enqueue_then_fetch() -> anyhow::Result<()> { ensure_rustls_provider(); let temp = TempDir::new()?; let base = temp.path(); let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); let local = tokio::task::LocalSet::new(); let state_path = base.join("recipient.bin"); local .run_until(cmd_register_state( &state_path, &server, &ca_cert, "localhost", None, )) .await?; let state_bytes = std::fs::read(&state_path)?; let stored: StoredStateCompat = bincode::deserialize(&state_bytes)?; let recipient_key = IdentityKeypair::from_seed(stored.identity_seed).public_key_bytes(); let identity_hex = hex_encode(&recipient_key); local .run_until(cmd_register_user( &server, &ca_cert, "localhost", "recipient", "pass", Some(&identity_hex), )) .await?; local .run_until(cmd_login( &server, &ca_cert, "localhost", "recipient", "pass", Some(&identity_hex), None, None, )) .await?; let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; local .run_until(enqueue(&client, &recipient_key, b"sealed-payload")) .await?; let payloads = local .run_until(fetch_wait(&client, &recipient_key, 500)) .await?; anyhow::ensure!( payloads.len() == 1 && payloads[0].1.as_slice() == b"sealed-payload", "expected one payload 'sealed-payload', got {:?}", payloads ); 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::(&std::fs::read(&alice_state)?)?.identity_seed; let bob_seed: [u8; 32] = bincode::deserialize::(&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::(&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::(&std::fs::read(&alice_state)?)?.identity_seed; let bob_seed = bincode::deserialize::(&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::>() ); 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::(&std::fs::read(&alice_state)?)?.identity_seed; let bob_seed = bincode::deserialize::(&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::>() ); } 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::>() ); } } Ok(()) } // ─── new tests: round-trip message delivery, key rotation, oversized payload, multi-party ───── /// Helper: load a state file and reconstruct a GroupMember with its keystore. fn load_member(state_path: &std::path::Path) -> (GroupMember, Option) { let bytes = std::fs::read(state_path).expect("read state"); let state: quicproquo_client::client::state::StoredState = bincode::deserialize(&bytes).expect("decode state"); state.into_parts(state_path).expect("into_parts") } /// Helper: save a GroupMember back to its state file. fn save_member(state_path: &std::path::Path, member: &GroupMember, hybrid: Option<&HybridKeypair>) { quicproquo_client::client::state::save_state(state_path, member, hybrid, None) .expect("save state"); } /// Basic happy-path: Alice registers, Bob registers, Alice creates a DM channel + MLS group, /// invites Bob, sends "ping", Bob fetches and decrypts "ping". #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_message_delivery_round_trip() -> 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"); // Register identity states (KeyPackage + hybrid key upload). 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::(&std::fs::read(&alice_state)?)?.identity_seed; let bob_seed = bincode::deserialize::(&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. 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, creates DM channel, MLS group, invites Bob. 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) = local .run_until(create_channel(&client, &bob_pk)) .await?; anyhow::ensure!(was_new, "Alice must get 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 logs in and 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?; // Alice sends "ping". init_auth(ClientAuth::from_raw(session_alice, None)); local .run_until(cmd_send( &alice_state, &server, &ca_cert, "localhost", Some(&bob_pk_hex), false, "ping", None, )) .await?; // Bob receives and decrypts. init_auth(ClientAuth::from_raw(session_bob, None)); let plaintexts = local .run_until(receive_pending_plaintexts( &bob_state, &server, &ca_cert, "localhost", 1500, None, )) .await?; anyhow::ensure!( plaintexts.iter().any(|p| p.as_slice() == b"ping"), "Bob did not receive 'ping'; got {:?}", plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() ); Ok(()) } /// Alice proposes a self-update (MLS key rotation) in a DM group with Bob. /// Alice commits the pending proposal and fans out the proposal + commit. /// Bob processes them. After rotation, Alice sends a message and Bob decrypts it. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_key_rotation_update_path() -> 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, &["--sealed-sender"]); 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::(&std::fs::read(&alice_state)?)?.identity_seed; let bob_seed = bincode::deserialize::(&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 bob_pk_hex = hex_encode(&bob_pk); // Set up the MLS group: Alice creates, invites Bob, Bob joins. local .run_until(cmd_create_group(&alice_state, &server, "rotation-test", None)) .await?; local .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None)) .await?; local .run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None)) .await?; // --- Key rotation via core MLS API --- // Load Alice's GroupMember, propose self-update, commit, save. let (mut alice_member, alice_hybrid) = load_member(&alice_state); let proposal = alice_member.propose_self_update()?; let (commit, _welcome) = alice_member.commit_pending_proposals()?; save_member(&alice_state, &alice_member, alice_hybrid.as_ref()); // Fan out proposal + commit to Bob via enqueue. let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; local.run_until(enqueue(&client, &bob_pk, &proposal)).await?; local.run_until(enqueue(&client, &bob_pk, &commit)).await?; // Bob fetches and processes the proposal + commit. let (mut bob_member, bob_hybrid) = load_member(&bob_state); let mut raw_payloads = local.run_until(fetch_wait(&client, &bob_pk, 1000)).await?; raw_payloads.sort_by_key(|(seq, _)| *seq); for (_, payload) in &raw_payloads { match bob_member.receive_message(payload) { Ok(ReceivedMessage::StateChanged) => {} Ok(other) => anyhow::bail!("expected StateChanged, got {other:?}"), Err(e) => anyhow::bail!("Bob failed to process rotation message: {e}"), } } save_member(&bob_state, &bob_member, bob_hybrid.as_ref()); // After rotation, Alice sends a message and Bob decrypts it. local .run_until(cmd_send( &alice_state, &server, &ca_cert, "localhost", Some(&bob_pk_hex), false, "post-rotation", None, )) .await?; let plaintexts = local .run_until(receive_pending_plaintexts( &bob_state, &server, &ca_cert, "localhost", 1500, None, )) .await?; anyhow::ensure!( plaintexts.iter().any(|p| p.as_slice() == b"post-rotation"), "Bob did not receive 'post-rotation' after key rotation; got {:?}", plaintexts.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() ); Ok(()) } /// Sending a payload larger than 5 MB must be rejected by the server with E006. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_hook_rejects_oversized_payload() -> anyhow::Result<()> { ensure_rustls_provider(); let temp = TempDir::new()?; let base = temp.path(); let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); let local = tokio::task::LocalSet::new(); // Register a recipient so enqueue has a valid target. let state_path = base.join("recipient.bin"); local .run_until(cmd_register_state(&state_path, &server, &ca_cert, "localhost", None)) .await?; let state_bytes = std::fs::read(&state_path)?; let stored: StoredStateCompat = bincode::deserialize(&state_bytes)?; let recipient_key = IdentityKeypair::from_seed(stored.identity_seed).public_key_bytes(); let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; // Payload just over the 5 MB limit (5 * 1024 * 1024 + 1 bytes). let oversized = vec![0xAAu8; 5 * 1024 * 1024 + 1]; let result = local.run_until(enqueue(&client, &recipient_key, &oversized)).await; match result { Ok(_) => anyhow::bail!("enqueue with oversized payload should have been rejected"), Err(e) => { let msg = format!("{e:#}"); anyhow::ensure!( msg.contains("payload exceeds max size") || msg.contains("E006"), "expected E006 / payload size error, got: {msg}" ); } } Ok(()) } /// Three-party group: Alice creates, invites Bob, then Carol. /// All three exchange messages and verify cross-member delivery: /// Alice -> group, Bob -> group, Carol -> group. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_multi_party_group() -> anyhow::Result<()> { ensure_rustls_provider(); let temp = TempDir::new()?; let base = temp.path(); let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); 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"); let carol_state = base.join("carol.bin"); // Register all three. 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?; local .run_until(cmd_register_state(&carol_state, &server, &ca_cert, "localhost", None)) .await?; let bob_bytes = std::fs::read(&bob_state)?; let bob_compat: StoredStateCompat = bincode::deserialize(&bob_bytes)?; let bob_pk_hex = hex_encode(&IdentityKeypair::from_seed(bob_compat.identity_seed).public_key_bytes()); let carol_bytes = std::fs::read(&carol_state)?; let carol_compat: StoredStateCompat = bincode::deserialize(&carol_bytes)?; let carol_pk_hex = hex_encode(&IdentityKeypair::from_seed(carol_compat.identity_seed).public_key_bytes()); // Alice creates group, invites Bob then Carol. local .run_until(cmd_create_group(&alice_state, &server, "trio", None)) .await?; local .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &bob_pk_hex, None)) .await?; local .run_until(cmd_invite(&alice_state, &server, &ca_cert, "localhost", &carol_pk_hex, None)) .await?; // Bob and Carol join. local .run_until(cmd_join(&bob_state, &server, &ca_cert, "localhost", None)) .await?; local .run_until(cmd_join(&carol_state, &server, &ca_cert, "localhost", None)) .await?; // Alice sends to all members. local .run_until(cmd_send(&alice_state, &server, &ca_cert, "localhost", None, true, "from-alice", None)) .await?; sleep(Duration::from_millis(200)).await; let bob_pt = local .run_until(receive_pending_plaintexts(&bob_state, &server, &ca_cert, "localhost", 1500, None)) .await?; let carol_pt = local .run_until(receive_pending_plaintexts(&carol_state, &server, &ca_cert, "localhost", 1500, None)) .await?; anyhow::ensure!( bob_pt.iter().any(|p| p.as_slice() == b"from-alice"), "Bob did not receive 'from-alice'; got {:?}", bob_pt.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() ); anyhow::ensure!( carol_pt.iter().any(|p| p.as_slice() == b"from-alice"), "Carol did not receive 'from-alice'; got {:?}", carol_pt.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() ); // Bob sends to all. local .run_until(cmd_send(&bob_state, &server, &ca_cert, "localhost", None, true, "from-bob", None)) .await?; sleep(Duration::from_millis(200)).await; let alice_pt = local .run_until(receive_pending_plaintexts(&alice_state, &server, &ca_cert, "localhost", 1500, None)) .await?; let carol_pt2 = local .run_until(receive_pending_plaintexts(&carol_state, &server, &ca_cert, "localhost", 1500, None)) .await?; anyhow::ensure!( alice_pt.iter().any(|p| p.as_slice() == b"from-bob"), "Alice did not receive 'from-bob'; got {:?}", alice_pt.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() ); anyhow::ensure!( carol_pt2.iter().any(|p| p.as_slice() == b"from-bob"), "Carol did not receive 'from-bob'; got {:?}", carol_pt2.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() ); // Carol sends to all. local .run_until(cmd_send(&carol_state, &server, &ca_cert, "localhost", None, true, "from-carol", None)) .await?; sleep(Duration::from_millis(200)).await; let alice_pt2 = local .run_until(receive_pending_plaintexts(&alice_state, &server, &ca_cert, "localhost", 1500, None)) .await?; let bob_pt2 = local .run_until(receive_pending_plaintexts(&bob_state, &server, &ca_cert, "localhost", 1500, None)) .await?; anyhow::ensure!( alice_pt2.iter().any(|p| p.as_slice() == b"from-carol"), "Alice did not receive 'from-carol'; got {:?}", alice_pt2.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() ); anyhow::ensure!( bob_pt2.iter().any(|p| p.as_slice() == b"from-carol"), "Bob did not receive 'from-carol'; got {:?}", bob_pt2.iter().map(|p| String::from_utf8_lossy(p).to_string()).collect::>() ); Ok(()) } // ─── blob upload / download tests ──────────────────────────────────────────── /// Upload a 2 KB blob, download it in full, then download a partial slice. /// Verifies SHA-256 integrity, blobId, and partial-range semantics. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_file_upload_download() -> anyhow::Result<()> { ensure_rustls_provider(); let temp = TempDir::new()?; let base = temp.path(); let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); let local = tokio::task::LocalSet::new(); // Register Alice (needed so the auth context is valid). let alice_state = base.join("alice.bin"); local .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) .await?; let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; // Build 2 KB of known data. let pattern = b"hello-world-file-test\n"; let repeat_count = 2048_usize.div_ceil(pattern.len()); let file_data: Vec = pattern.iter().copied().cycle().take(repeat_count * pattern.len()).collect(); let file_data = &file_data[..2048]; // exactly 2 KB // Compute SHA-256. let hash: [u8; 32] = Sha256::digest(file_data).into(); // ── Upload ── let blob_id = local .run_until(async { let mut req = client.upload_blob_request(); { let mut p = req.get(); let mut auth = p.reborrow().init_auth(); quicproquo_client::client::rpc::set_auth(&mut auth)?; p.set_blob_hash(&hash); p.set_chunk(file_data); p.set_offset(0); p.set_total_size(file_data.len() as u64); p.set_mime_type("application/octet-stream"); } let resp = req.send().promise.await .map_err(|e| anyhow::anyhow!("uploadBlob RPC failed: {e}"))?; let blob_id = resp.get() .map_err(|e| anyhow::anyhow!("uploadBlob bad response: {e}"))? .get_blob_id() .map_err(|e| anyhow::anyhow!("uploadBlob missing blobId: {e}"))? .to_vec(); Ok::, anyhow::Error>(blob_id) }) .await?; anyhow::ensure!( blob_id == hash, "blobId must equal SHA-256 hash; got {} vs {}", hex_encode(&blob_id), hex_encode(&hash) ); // ── Full download ── let (chunk, total_size) = local .run_until(async { let mut req = client.download_blob_request(); { let mut p = req.get(); let mut auth = p.reborrow().init_auth(); quicproquo_client::client::rpc::set_auth(&mut auth)?; p.set_blob_id(&blob_id); p.set_offset(0); p.set_length(file_data.len() as u32); } let resp = req.send().promise.await .map_err(|e| anyhow::anyhow!("downloadBlob RPC failed: {e}"))?; let r = resp.get() .map_err(|e| anyhow::anyhow!("downloadBlob bad response: {e}"))?; let chunk = r.get_chunk() .map_err(|e| anyhow::anyhow!("downloadBlob missing chunk: {e}"))? .to_vec(); let total = r.get_total_size(); Ok::<(Vec, u64), anyhow::Error>((chunk, total)) }) .await?; anyhow::ensure!( total_size == file_data.len() as u64, "totalSize mismatch: {} vs {}", total_size, file_data.len() ); anyhow::ensure!( chunk == file_data, "downloaded data does not match uploaded data (len {} vs {})", chunk.len(), file_data.len() ); // ── Partial download: offset=100, length=200 ── let partial = local .run_until(async { let mut req = client.download_blob_request(); { let mut p = req.get(); let mut auth = p.reborrow().init_auth(); quicproquo_client::client::rpc::set_auth(&mut auth)?; p.set_blob_id(&blob_id); p.set_offset(100); p.set_length(200); } let resp = req.send().promise.await .map_err(|e| anyhow::anyhow!("downloadBlob partial RPC failed: {e}"))?; let r = resp.get() .map_err(|e| anyhow::anyhow!("downloadBlob partial bad response: {e}"))?; let chunk = r.get_chunk() .map_err(|e| anyhow::anyhow!("downloadBlob partial missing chunk: {e}"))? .to_vec(); Ok::, anyhow::Error>(chunk) }) .await?; anyhow::ensure!( partial == file_data[100..300], "partial download [100..300] does not match expected slice" ); Ok(()) } /// Uploading with a blobHash that does not match the chunk data must return E026. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_blob_hash_mismatch() -> anyhow::Result<()> { ensure_rustls_provider(); let temp = TempDir::new()?; let base = temp.path(); let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); 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"); local .run_until(cmd_register_state(&alice_state, &server, &ca_cert, "localhost", None)) .await?; let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; // Chunk data. let chunk_data = b"some file content for mismatch test"; // Wrong hash (all zeros — will not match any real data). let wrong_hash = [0u8; 32]; let result = local .run_until(async { let mut req = client.upload_blob_request(); { let mut p = req.get(); let mut auth = p.reborrow().init_auth(); quicproquo_client::client::rpc::set_auth(&mut auth)?; p.set_blob_hash(&wrong_hash); p.set_chunk(&chunk_data[..]); p.set_offset(0); p.set_total_size(chunk_data.len() as u64); p.set_mime_type("text/plain"); } let resp = req.send().promise.await .map_err(|e| anyhow::anyhow!("uploadBlob RPC: {e}"))?; resp.get() .map_err(|e| anyhow::anyhow!("uploadBlob response: {e}"))?; Ok::<(), anyhow::Error>(()) }) .await; match result { Ok(_) => anyhow::bail!("uploadBlob with wrong hash should have been rejected"), Err(e) => { let msg = format!("{e:#}"); anyhow::ensure!( msg.contains("E026") || msg.contains("hash") || msg.contains("mismatch"), "expected E026 / hash mismatch error, got: {msg}" ); } } Ok(()) } // ─── Phase 2.1 tests: auth failure, message ordering, OPAQUE, key exhaustion, rate limit ───── /// Spawns a server with fully custom args (no default --auth-token / --allow-insecure-auth). fn spawn_server_custom(base: &std::path::Path, args: &[&str]) -> (String, PathBuf, ChildGuard) { let port = pick_unused_port().expect("free port"); let listen = format!("127.0.0.1:{port}"); let ca_cert = base.join("server-cert.der"); let tls_key = base.join("server-key.der"); let data_dir = base.join("data"); let server_bin = cargo_bin("qpq-server"); let mut cmd = Command::new(server_bin); cmd.arg("--listen") .arg(&listen) .arg("--data-dir") .arg(&data_dir) .arg("--tls-cert") .arg(&ca_cert) .arg("--tls-key") .arg(&tls_key); for arg in args { cmd.arg(arg); } let child = cmd.spawn().expect("spawn server"); (listen, ca_cert, ChildGuard(child)) } /// Using a wrong auth token should cause operations to fail with an auth error. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_auth_failure_wrong_token() -> anyhow::Result<()> { ensure_rustls_provider(); let temp = TempDir::new()?; let base = temp.path(); let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; // Set auth to a WRONG token (server expects "devtoken"). init_auth(ClientAuth::from_parts("wrongtoken".to_string(), None)); let local = tokio::task::LocalSet::new(); let state_path = base.join("user.bin"); // Try to register state with the wrong token — should fail with auth error. let result = local .run_until(cmd_register_state( &state_path, &server, &ca_cert, "localhost", None, )) .await; match result { Ok(_) => anyhow::bail!("register_state with wrong token should have been rejected"), Err(e) => { let msg = format!("{e:#}"); anyhow::ensure!( msg.contains("auth") || msg.contains("E001") || msg.contains("E003") || msg.contains("token") || msg.contains("Token") || msg.contains("denied") || msg.contains("unauthorized") || msg.contains("Unauthorized"), "expected auth-related error, got: {msg}" ); } } Ok(()) } /// Send 10 ordered messages and verify they arrive in the correct order. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_message_ordering_preserved() -> 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_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts(auth_token.to_string(), None)); let local = tokio::task::LocalSet::new(); let a_state = base.join("a.bin"); let b_state = base.join("b.bin"); local .run_until(cmd_register_state(&a_state, &server, &ca_cert, "localhost", None)) .await?; local .run_until(cmd_register_state(&b_state, &server, &ca_cert, "localhost", None)) .await?; let b_bytes = std::fs::read(&b_state)?; let b_compat: StoredStateCompat = bincode::deserialize(&b_bytes)?; let b_pk_hex = hex_encode(&IdentityKeypair::from_seed(b_compat.identity_seed).public_key_bytes()); // A creates group, invites B, B joins. local .run_until(cmd_create_group(&a_state, &server, "order-test", None)) .await?; local .run_until(cmd_invite(&a_state, &server, &ca_cert, "localhost", &b_pk_hex, None)) .await?; local .run_until(cmd_join(&b_state, &server, &ca_cert, "localhost", None)) .await?; // A sends 10 messages with bodies "msg-0" through "msg-9". for i in 0..10u32 { let body = format!("msg-{i}"); local .run_until(cmd_send( &a_state, &server, &ca_cert, "localhost", Some(&b_pk_hex), false, &body, None, )) .await?; } sleep(Duration::from_millis(300)).await; // B fetches all messages and verifies ordering. let plaintexts = local .run_until(receive_pending_plaintexts( &b_state, &server, &ca_cert, "localhost", 2000, None, )) .await?; // Filter to only the msg-N messages (there may be MLS group state messages). let ordered: Vec = plaintexts .iter() .filter_map(|p| { let s = String::from_utf8_lossy(p); if s.starts_with("msg-") { Some(s.to_string()) } else { None } }) .collect(); anyhow::ensure!( ordered.len() == 10, "expected 10 msg-N messages, got {} : {:?}", ordered.len(), ordered ); for (i, msg) in ordered.iter().enumerate() { let expected = format!("msg-{i}"); anyhow::ensure!( *msg == expected, "message at position {i} is '{msg}', expected '{expected}'; full sequence: {:?}", ordered ); } Ok(()) } /// Full OPAQUE register + login flow on an OPAQUE-only server (no --allow-insecure-auth). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_opaque_register_login_full_flow() -> anyhow::Result<()> { ensure_rustls_provider(); let _auth = AUTH_LOCK.lock().unwrap(); let temp = TempDir::new()?; let base = temp.path(); // Server with auth token but WITHOUT --allow-insecure-auth (OPAQUE required for identity). let (server, ca_cert, _child) = spawn_server_custom(base, &["--auth-token", "devtoken", "--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; let local = tokio::task::LocalSet::new(); // Register user via OPAQUE (register RPCs are unauthenticated). let alice_identity = IdentityKeypair::generate(); let alice_pk = alice_identity.public_key_bytes().to_vec(); let alice_pk_hex = hex_encode(&alice_pk); local .run_until(cmd_register_user( &server, &ca_cert, "localhost", "alice", "correct-password", Some(&alice_pk_hex), )) .await?; // Login with correct credentials. let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; let session_token = local .run_until(opaque_login(&client, "alice", "correct-password", &alice_pk)) .await?; anyhow::ensure!( !session_token.is_empty(), "OPAQUE login must return a non-empty session token" ); // Verify the session works: set it as auth and call resolve_user on ourselves. init_auth(ClientAuth::from_raw(session_token, None)); let resolved = local.run_until(resolve_user(&client, "alice")).await?; anyhow::ensure!( resolved == Some(alice_pk), "resolve_user('alice') must return alice's identity key after OPAQUE login" ); Ok(()) } /// OPAQUE login with wrong password must fail. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_opaque_login_wrong_password() -> 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_custom(base, &["--auth-token", "devtoken", "--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; let local = tokio::task::LocalSet::new(); let alice_identity = IdentityKeypair::generate(); let alice_pk = alice_identity.public_key_bytes().to_vec(); let alice_pk_hex = hex_encode(&alice_pk); // Register with correct password. local .run_until(cmd_register_user( &server, &ca_cert, "localhost", "alice", "correct-password", Some(&alice_pk_hex), )) .await?; // Try login with WRONG password. let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; let result = local .run_until(opaque_login(&client, "alice", "wrong-password", &alice_pk)) .await; match result { Ok(_) => anyhow::bail!("OPAQUE login with wrong password should have failed"), Err(e) => { let msg = format!("{e:#}"); anyhow::ensure!( msg.contains("password") || msg.contains("OPAQUE") || msg.contains("login") || msg.contains("credential") || msg.contains("InvalidLogin") || msg.contains("finish"), "expected OPAQUE login failure, got: {msg}" ); } } Ok(()) } /// After A's single KeyPackage is consumed by an invite, fetching it again must return empty /// (graceful degradation, not a panic). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_keypackage_exhaustion_graceful() -> anyhow::Result<()> { ensure_rustls_provider(); let temp = TempDir::new()?; let base = temp.path(); let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); let local = tokio::task::LocalSet::new(); // Register A (uploads 1 KeyPackage). let a_state = base.join("a.bin"); local .run_until(cmd_register_state(&a_state, &server, &ca_cert, "localhost", None)) .await?; let a_bytes = std::fs::read(&a_state)?; let a_compat: StoredStateCompat = bincode::deserialize(&a_bytes)?; let a_pk = IdentityKeypair::from_seed(a_compat.identity_seed).public_key_bytes(); // Register B, create group, invite A (consumes A's KeyPackage). let b_state = base.join("b.bin"); local .run_until(cmd_register_state(&b_state, &server, &ca_cert, "localhost", None)) .await?; local .run_until(cmd_create_group(&b_state, &server, "exhaust-test", None)) .await?; let a_pk_hex = hex_encode(&a_pk); local .run_until(cmd_invite(&b_state, &server, &ca_cert, "localhost", &a_pk_hex, None)) .await?; // Now try to fetch A's KeyPackage again — it should be exhausted. let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; let pkg = local .run_until(quicproquo_client::client::rpc::fetch_key_package(&client, &a_pk)) .await?; // Graceful: either empty (no package available) or an error — but NOT a panic. anyhow::ensure!( pkg.is_empty(), "expected empty KeyPackage after exhaustion, got {} bytes", pkg.len() ); Ok(()) } /// Sending more than 100 enqueues in the rate-limit window must eventually be rejected. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn e2e_rate_limiting_rejects_excess() -> anyhow::Result<()> { ensure_rustls_provider(); let temp = TempDir::new()?; let base = temp.path(); let (server, ca_cert, _child) = spawn_server(base, &["--sealed-sender"]); wait_for_health(&server, &ca_cert, "localhost").await?; init_auth(ClientAuth::from_parts("devtoken".to_string(), None)); let local = tokio::task::LocalSet::new(); // Register a recipient. let state_path = base.join("recipient.bin"); local .run_until(cmd_register_state(&state_path, &server, &ca_cert, "localhost", None)) .await?; let state_bytes = std::fs::read(&state_path)?; let stored: StoredStateCompat = bincode::deserialize(&state_bytes)?; let recipient_key = IdentityKeypair::from_seed(stored.identity_seed).public_key_bytes(); let client = local.run_until(connect_node(&server, &ca_cert, "localhost")).await?; // Send 110 enqueues rapidly — the server limit is 100 per 60s window. let mut hit_rate_limit = false; for i in 0u32..110 { let payload = format!("spam-{i}"); let result = local .run_until(enqueue(&client, &recipient_key, payload.as_bytes())) .await; if let Err(e) = result { let msg = format!("{e:#}"); if msg.contains("rate limit") || msg.contains("E014") || msg.contains("Rate") { hit_rate_limit = true; break; } // Other errors (e.g. connection reset) might happen after rate limit — accept them. hit_rate_limit = true; break; } } anyhow::ensure!( hit_rate_limit, "expected rate limit error after 100+ enqueues, but all 110 succeeded" ); Ok(()) }