Files
quicproquo/crates/quicnprotochat-client/tests/e2e.rs
Christian Nennemann 0bdc222724 fix: address 16 architecture design flaws across all crates
Phase 1 — Foundation:
- Constant-time token comparison via subtle::ConstantTimeEq (Fix 11)
- Structured error codes E001–E020 in new error_codes.rs (Fix 15)
- Remove dead envelope.capnp code and related types (Fix 16)

Phase 2 — Auth Hardening:
- Registration collision check via has_user_record() (Fix 5)
- Auth required on uploadHybridKey/fetchHybridKey RPCs (Fix 1)
- Identity-token binding at registration and login (Fix 2)
- Session token expiry with 24h TTL and background reaper (Fix 3)
- Bounded pending logins with 5-minute timeout (Fix 4)

Phase 3 — Resource Limits:
- Rate limiting: 100 enqueues/60s per token (Fix 6)
- Queue depth cap at 1000 + 7-day message TTL/GC (Fix 7)
- Partial queue drain via limit param on fetch/fetchWait (Fix 8)

Phase 4 — Crypto Fixes:
- OPAQUE KSF switched from Identity to Argon2id (Fix 10)
- Random AEAD nonce in hybrid KEM instead of HKDF-derived (Fix 12)
- Zeroize secret fields in HybridKeypairBytes (Fix 13)
- Encrypted client state files via QPCE format (Fix 9)

Phase 5 — Protocol:
- Commit fan-out to all existing members on invite (Fix 14)
- Add member_identities() to GroupMember

Breaking: existing OPAQUE registrations invalidated (Argon2 KSF).
Schema: added auth to hybrid key ops, identityKey to OPAQUE finish
RPCs, limit to fetch/fetchWait.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 10:51:09 +01:00

171 lines
4.5 KiB
Rust

use std::{path::PathBuf, process::Command, time::Duration};
use assert_cmd::cargo::cargo_bin;
use portpicker::pick_unused_port;
use tempfile::TempDir;
use tokio::time::sleep;
use quicnprotochat_client::{
cmd_create_group, cmd_invite, cmd_join, cmd_ping, cmd_register_state, cmd_send, ClientAuth,
connect_node, fetch_wait, init_auth,
};
use quicnprotochat_core::IdentityKeypair;
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<Vec<u8>>,
}
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")
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn e2e_happy_path_register_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";
// Spawn server binary.
let server_bin = cargo_bin("quicnprotochat-server");
let mut 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)
.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));
// LocalSet for capnp !Send operations.
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?;
local
.run_until(cmd_create_group(
&alice_state,
&server,
"test-group",
None,
))
.await?;
// Load Bob identity key from persisted state to use as peer key.
let bob_bytes = std::fs::read(&bob_state)?;
let bob_state_compat: StoredStateCompat = bincode::deserialize(&bob_bytes)?;
let bob_identity = IdentityKeypair::from_seed(bob_state_compat.identity_seed);
let bob_pk_hex = hex_encode(&bob_identity.public_key_bytes());
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?;
// Send Alice -> Bob.
local
.run_until(cmd_send(
&alice_state,
&server,
&ca_cert,
"localhost",
&bob_pk_hex,
"hello bob",
None,
))
.await?;
// Confirm Bob can fetch at least one payload.
local
.run_until(async {
let client = connect_node(&server, &ca_cert, "localhost").await?;
let payloads = fetch_wait(&client, &bob_identity.public_key_bytes(), 1000).await?;
anyhow::ensure!(!payloads.is_empty(), "no payloads delivered to Bob");
Ok::<(), anyhow::Error>(())
})
.await?;
Ok(())
}