From 5752c00463084b5b894458cc6fc3dea18b5f64ae Mon Sep 17 00:00:00 2001 From: kaichaosun Date: Thu, 11 Jun 2026 16:36:39 +0800 Subject: [PATCH] smoke test script --- Cargo.lock | 168 +++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 5 ++ README.md | 26 ++++++- examples/smoke_test.rs | 129 +++++++++++++++++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 examples/smoke_test.rs diff --git a/Cargo.lock b/Cargo.lock index b824826..2bc4819 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -190,6 +190,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -228,6 +234,7 @@ dependencies = [ "clap", "ed25519-dalek", "hex", + "reqwest", "serde", "serde_json", "sqlx", @@ -742,6 +749,7 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", ] [[package]] @@ -750,13 +758,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -872,6 +888,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -884,6 +906,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1248,6 +1281,40 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "rsa" version = "0.9.10" @@ -1698,6 +1765,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1818,6 +1888,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1892,6 +1980,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -1967,6 +2061,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1979,6 +2082,71 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index bf673f4..0dc0376 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,3 +30,8 @@ sqlx = { version = "0.8", default-features = false, features = [ tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal", "sync", "time"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +# `smoke_test` example only talks to a localhost HTTP server, so no TLS backend +# (and no OpenSSL) is pulled in. +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json"] } diff --git a/README.md b/README.md index 05bbd68..a6cf22f 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,31 @@ within `--retention-days`. The schema is an internal detail and may change. ## Smoke test -End-to-end check with the real `chat-cli` (which lives in the +The quickest end-to-end check is the bundled [`smoke_test`](examples/smoke_test.rs) +example. It generates throwaway Ed25519 keys, signs and publishes a keypackage and +an account bundle, fetches both back, and confirms the replay guard: + +```bash +# Terminal 1 — start a server with a fresh db +cargo run -- --bind 127.0.0.1:8080 --db tmp/chat-store.db + +# Terminal 2 — run the example against it (defaults to http://127.0.0.1:8080) +cargo run --example smoke_test +# or point it elsewhere: +cargo run --example smoke_test -- http://127.0.0.1:8080 +``` + +Expected output: + +```text +POST /v0/keypackage -> 204 No Content (expect 204) +GET /v0/keypackage/ -> 200 OK (expect 200) {"payload":...,"signature":...} +POST /v0/account -> 204 No Content (expect 204) +GET /v0/account/ -> 200 OK (expect 200) {"payload":...,"signature":...,"updated_at":...} +POST /v0/account (replay) -> 409 Conflict (expect 409) +``` + +You can also exercise it with the real `chat-cli` (which lives in the [libchat](https://github.com/logos-messaging/libchat) repo) against a running server: diff --git a/examples/smoke_test.rs b/examples/smoke_test.rs new file mode 100644 index 0000000..9fa6246 --- /dev/null +++ b/examples/smoke_test.rs @@ -0,0 +1,129 @@ +//! End-to-end smoke test for the chat-store HTTP API. +//! +//! Generates throwaway Ed25519 keys, signs a keypackage bundle and an account +//! device-list bundle, POSTs both, then GETs them back. The server verifies the +//! signature over the exact payload bytes, so the bundles must be properly +//! signed — this example does that for you. +//! +//! Run it against a live server: +//! +//! ```text +//! cargo run -- --bind 127.0.0.1:8080 --db tmp/chat-store.db # terminal 1 +//! cargo run --example smoke_test # terminal 2 +//! cargo run --example smoke_test -- http://host:port # custom target +//! ``` + +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use ed25519_dalek::{Signer, SigningKey}; +use reqwest::blocking::Client; +use serde_json::json; + +/// Domain-separation prefix the server expects on every account bundle payload. +/// Must match `BUNDLE_DOMAIN` in `src/store.rs` (and libchat's account_directory). +const ACCOUNT_BUNDLE_DOMAIN: &[u8] = b"libchat:account-device-bundle\0"; + +fn main() -> Result<()> { + let base = std::env::args() + .nth(1) + .unwrap_or_else(|| "http://127.0.0.1:8080".to_string()); + let base = base.trim_end_matches('/'); + let client = Client::new(); + + println!("Testing chat-store at {base}"); + test_keypackage(&client, base)?; + test_account(&client, base)?; + Ok(()) +} + +fn test_keypackage(client: &Client, base: &str) -> Result<()> { + let key = signing_key(1); + let device_id = pub_hex(&key); + + // The keypackage payload is opaque to the server: any bytes work as long as + // the signature matches. Real clients put `timestamp_ms_le[8] || key_package`. + let mut payload = 0u64.to_le_bytes().to_vec(); + payload.extend_from_slice(b"hello-keypackage"); + + let resp = client + .post(format!("{base}/v0/keypackage")) + .json(&json!({ + "device_id": device_id, + "payload": BASE64.encode(&payload), + "signature": BASE64.encode(key.sign(&payload).to_bytes()), + })) + .send()?; + println!("POST /v0/keypackage -> {} (expect 204)", resp.status()); + + let resp = client + .get(format!("{base}/v0/keypackage/{device_id}")) + .send()?; + println!( + "GET /v0/keypackage/ -> {} (expect 200) {}", + resp.status(), + resp.text()? + ); + Ok(()) +} + +fn test_account(client: &Client, base: &str) -> Result<()> { + let key = signing_key(2); + let account_id = pub_hex(&key); + + // The account payload is NOT arbitrary: it must start with the domain prefix, + // a version byte, and an 8-byte little-endian lamport so the server can run + // its replay check. Device pubkeys would follow, but stay opaque to the server. + let lamport: u64 = 1; + let mut payload = ACCOUNT_BUNDLE_DOMAIN.to_vec(); + payload.push(1); // version + payload.extend_from_slice(&lamport.to_le_bytes()); + + let body = json!({ + "account_id": account_id, + "payload": BASE64.encode(&payload), + "signature": BASE64.encode(key.sign(&payload).to_bytes()), + }); + + let resp = client + .post(format!("{base}/v0/account")) + .json(&body) + .send()?; + println!("POST /v0/account -> {} (expect 204)", resp.status()); + + let resp = client.get(format!("{base}/v0/account/{account_id}")).send()?; + println!( + "GET /v0/account/ -> {} (expect 200) {}", + resp.status(), + resp.text()? + ); + + // Re-posting the same lamport must be rejected as a stale replay. + let resp = client + .post(format!("{base}/v0/account")) + .json(&body) + .send()?; + println!("POST /v0/account (replay) -> {} (expect 409)", resp.status()); + Ok(()) +} + +/// A throwaway Ed25519 signing key seeded from the current time (plus a salt so +/// the keypackage and account keys differ), so repeated runs use fresh ids. +fn signing_key(salt: u8) -> SigningKey { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + .to_le_bytes(); + let mut seed = [0u8; 32]; + for (i, b) in seed.iter_mut().enumerate() { + *b = nanos[i % nanos.len()] ^ salt ^ (i as u8); + } + SigningKey::from_bytes(&seed) +} + +fn pub_hex(key: &SigningKey) -> String { + hex::encode(key.verifying_key().to_bytes()) +}