Files
quicproquo/crates/quicnprotochat-client/tests/e2e.rs

594 lines
16 KiB
Rust

// cargo_bin! only works for current package's binary; we spawn quicnprotochat-server from another package.
#![allow(deprecated)]
use std::{path::PathBuf, process::Command, time::Duration};
use assert_cmd::cargo::cargo_bin;
use portpicker::pick_unused_port;
use rand::RngCore;
use tempfile::TempDir;
use tokio::time::sleep;
use hex;
// 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 quicnprotochat_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,
load_existing_state, receive_pending_plaintexts, ClientAuth,
};
use quicnprotochat_core::IdentityKeypair;
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
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 {
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")
}
/// 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 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("quicnprotochat-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");
// Ensure we always terminate the child.
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;
// 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();
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,
false,
))
.await?;
local
.run_until(cmd_register_state(
&joiner_state,
&server,
&ca_cert,
"localhost",
None,
false,
))
.await?;
local
.run_until(cmd_create_group(&creator_state, &server, "test-group", None, false))
.await?;
let joiner_state_loaded = load_existing_state(&joiner_state, None)?;
let joiner_identity = IdentityKeypair::from_seed(joiner_state_loaded.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 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("quicnprotochat-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);
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,
false,
))
.await?;
local
.run_until(cmd_register_state(
&b_state,
&server,
&ca_cert,
"localhost",
None,
false,
))
.await?;
local
.run_until(cmd_register_state(
&c_state,
&server,
&ca_cert,
"localhost",
None,
false,
))
.await?;
let b_loaded = load_existing_state(&b_state, None)?;
let b_pk_hex = hex_encode(&IdentityKeypair::from_seed(b_loaded.identity_seed).public_key_bytes());
let c_loaded = load_existing_state(&c_state, None)?;
let c_pk_hex = hex_encode(&IdentityKeypair::from_seed(c_loaded.identity_seed).public_key_bytes());
local
.run_until(cmd_create_group(&creator_state, &server, "test-group", None, false))
.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 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("quicnprotochat-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;
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 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,
false,
))
.await?;
// Register the user with the bound identity so login can enforce mismatches.
let stored_state = load_existing_state(&state_path, None)?;
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) => {
// 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"),
"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 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("quicnprotochat-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);
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 state_path = base.join("recipient.bin");
local
.run_until(cmd_register_state(
&state_path,
&server,
&ca_cert,
"localhost",
None,
false,
))
.await?;
let stored = load_existing_state(&state_path, None)?;
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(())
}