//! Integration tests: stand up the real router on a random local port and //! drive it with `reqwest`. No mocks — exercises the full HTTP + SQLite + //! kez-core validation path. use std::net::SocketAddr; use chrono::Utc; use kez_core::{ Identity, NostrSecret, SigchainEventPayload, SignedSigchainEvent, }; use kez_sig_server::{AppState, Store, router}; use reqwest::StatusCode; use serde_json::Value; struct TestServer { base: String, #[allow(dead_code)] handle: tokio::task::JoinHandle<()>, } async fn spawn_server() -> TestServer { let store = Store::open_in_memory().unwrap(); let app = router(AppState { store }); let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) .await .unwrap(); let addr = listener.local_addr().unwrap(); let handle = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); TestServer { base: format!("http://{addr}"), handle, } } fn fresh_nostr() -> (NostrSecret, Identity) { let s = NostrSecret::generate(); let id = Identity::parse(format!("nostr:{}", s.npub())).unwrap(); (s, id) } fn signed_add( secret: &NostrSecret, primary: &Identity, seq: u64, prev: Option, subject: &str, ) -> SignedSigchainEvent { let payload = SigchainEventPayload::new_add( primary.clone(), seq, prev, Identity::parse(subject).unwrap(), None, Utc::now(), ); SignedSigchainEvent::sign(payload, secret).unwrap() } fn chain_url(base: &str, primary: &Identity) -> String { format!("{base}/v1/sigchains/{}/{}", primary.scheme(), primary.value()) } #[tokio::test] async fn healthz_returns_ok() { let server = spawn_server().await; let resp = reqwest::get(format!("{}/v1/healthz", server.base)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: Value = resp.json().await.unwrap(); assert_eq!(body["status"], "ok"); } #[tokio::test] async fn empty_chain_returns_404() { let server = spawn_server().await; let (_, primary) = fresh_nostr(); let resp = reqwest::get(chain_url(&server.base, &primary)).await.unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn post_then_get_round_trip() { let server = spawn_server().await; let (secret, primary) = fresh_nostr(); let event = signed_add(&secret, &primary, 0, None, "github:jason"); let client = reqwest::Client::new(); let post = client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&event) .send() .await .unwrap(); assert_eq!(post.status(), StatusCode::CREATED); let posted: Value = post.json().await.unwrap(); assert_eq!(posted["seq"], 0); let head_hash_after_post = posted["hash"].as_str().unwrap().to_owned(); assert!(head_hash_after_post.starts_with("sha256:")); // GET returns the event we just stored, as JSONL. let get = reqwest::get(chain_url(&server.base, &primary)).await.unwrap(); assert_eq!(get.status(), StatusCode::OK); assert_eq!( get.headers()["content-type"] .to_str() .unwrap() .split(';') .next() .unwrap(), "application/jsonl" ); let body = get.text().await.unwrap(); let lines: Vec<&str> = body.trim().lines().collect(); assert_eq!(lines.len(), 1); let round_tripped: SignedSigchainEvent = serde_json::from_str(lines[0]).unwrap(); assert_eq!(round_tripped, event); } #[tokio::test] async fn head_endpoint_returns_latest() { let server = spawn_server().await; let (secret, primary) = fresh_nostr(); let client = reqwest::Client::new(); // seq 0 let e0 = signed_add(&secret, &primary, 0, None, "github:a"); client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&e0) .send() .await .unwrap(); // seq 1 — prev = sha256 of e0 envelope let e1 = signed_add(&secret, &primary, 1, Some(e0.hash().unwrap()), "github:b"); client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&e1) .send() .await .unwrap(); let head_resp = reqwest::get(format!("{}/head", chain_url(&server.base, &primary))) .await .unwrap(); assert_eq!(head_resp.status(), StatusCode::OK); let head: SignedSigchainEvent = head_resp.json().await.unwrap(); assert_eq!(head, e1); } #[tokio::test] async fn rejects_event_for_wrong_primary_url() { let server = spawn_server().await; let (secret_a, primary_a) = fresh_nostr(); let (_secret_b, primary_b) = fresh_nostr(); // Event signed for A's key, but POSTed under B's URL. let event = signed_add(&secret_a, &primary_a, 0, None, "github:jason"); let client = reqwest::Client::new(); let resp = client .post(format!("{}/events", chain_url(&server.base, &primary_b))) .json(&event) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn rejects_bad_signature() { let server = spawn_server().await; let (secret, primary) = fresh_nostr(); let mut event = signed_add(&secret, &primary, 0, None, "github:jason"); // Flip the sig hex's last byte (still 128 chars, but no longer valid). let mut sig_bytes = event.signature.sig.into_bytes(); let last = sig_bytes.len() - 1; sig_bytes[last] = if sig_bytes[last] == b'a' { b'b' } else { b'a' }; event.signature.sig = String::from_utf8(sig_bytes).unwrap(); let client = reqwest::Client::new(); let resp = client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&event) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } #[tokio::test] async fn rejects_seq_skip() { let server = spawn_server().await; let (secret, primary) = fresh_nostr(); let client = reqwest::Client::new(); // seq 0 — succeeds let e0 = signed_add(&secret, &primary, 0, None, "github:a"); let r0 = client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&e0) .send() .await .unwrap(); assert_eq!(r0.status(), StatusCode::CREATED); // seq 2 (skipping 1) — must be rejected as Conflict let e2 = signed_add(&secret, &primary, 2, Some(e0.hash().unwrap()), "github:b"); let r2 = client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&e2) .send() .await .unwrap(); assert_eq!(r2.status(), StatusCode::CONFLICT); } #[tokio::test] async fn rejects_bad_prev_hash() { let server = spawn_server().await; let (secret, primary) = fresh_nostr(); let client = reqwest::Client::new(); let e0 = signed_add(&secret, &primary, 0, None, "github:a"); client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&e0) .send() .await .unwrap(); // seq 1 with a bogus prev hash. let e1 = signed_add(&secret, &primary, 1, Some("sha256:dead".into()), "github:b"); let resp = client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&e1) .send() .await .unwrap(); assert_eq!(resp.status(), StatusCode::CONFLICT); } #[tokio::test] async fn rejects_duplicate_seq() { let server = spawn_server().await; let (secret, primary) = fresh_nostr(); let client = reqwest::Client::new(); let e0 = signed_add(&secret, &primary, 0, None, "github:a"); let r0 = client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&e0) .send() .await .unwrap(); assert_eq!(r0.status(), StatusCode::CREATED); // Posting the same seq-0 event again must fail. let r0_dup = client .post(format!("{}/events", chain_url(&server.base, &primary))) .json(&e0) .send() .await .unwrap(); assert_eq!(r0_dup.status(), StatusCode::CONFLICT); } #[tokio::test] async fn invalid_primary_in_url_is_bad_request() { let server = spawn_server().await; // Not a valid `system:value` shape (no value after the colon). let resp = reqwest::get(format!("{}/v1/sigchains/nostr/garbage", server.base)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::BAD_REQUEST); }