Files
buzz/crates/sprout-test-client/tests/e2e_relay.rs
2026-03-10 15:26:19 -07:00

627 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! End-to-end integration tests for the Sprout relay.
//!
//! These tests require a running relay instance. By default they are marked
//! `#[ignore]` so that `cargo test` does not fail in CI when the relay is not
//! available.
//!
//! # Running
//!
//! Start the relay, then run:
//!
//! ```text
//! cargo test --test e2e_relay -- --ignored
//! ```
//!
//! Override the relay URL with the `RELAY_URL` environment variable:
//!
//! ```text
//! RELAY_URL=ws://relay.example.com cargo test --test e2e_relay -- --ignored
//! ```
use std::time::Duration;
use nostr::{Alphabet, Filter, Keys, Kind, SingleLetterTag};
use sprout_test_client::{RelayMessage, SproutTestClient, TestClientError};
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
fn sub_id(name: &str) -> String {
format!("e2e-{name}-{}", uuid::Uuid::new_v4())
}
fn channel_id(name: &str) -> String {
format!("test-channel-{name}-{}", uuid::Uuid::new_v4())
}
#[tokio::test]
#[ignore]
async fn test_connect_and_authenticate() {
let url = relay_url();
let keys = Keys::generate();
let client = SproutTestClient::connect(&url, &keys)
.await
.expect("should connect and authenticate");
client.disconnect().await.expect("clean disconnect");
}
#[tokio::test]
#[ignore]
async fn test_send_event_and_receive_via_subscription() {
let url = relay_url();
let channel = channel_id("send-recv");
let kind: u16 = 40001;
let keys_a = Keys::generate();
let keys_b = Keys::generate();
let mut client_a = SproutTestClient::connect(&url, &keys_a)
.await
.expect("client A connect");
let sid = sub_id("send-recv");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]);
client_a
.subscribe(&sid, vec![filter])
.await
.expect("client A subscribe");
// Drain EOSE so we're ready for live events.
client_a
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("client A EOSE");
let mut client_b = SproutTestClient::connect(&url, &keys_b)
.await
.expect("client B connect");
let content = format!("hello from B at {}", uuid::Uuid::new_v4());
let ok = client_b
.send_text_message(&keys_b, &channel, &content, kind)
.await
.expect("client B send");
assert!(ok.accepted, "relay rejected event: {}", ok.message);
let msg = client_a
.recv_event(Duration::from_secs(5))
.await
.expect("client A recv");
match msg {
RelayMessage::Event { event, .. } => {
assert_eq!(event.content, content);
assert_eq!(event.pubkey, keys_b.public_key());
}
other => panic!("Expected Event, got {other:?}"),
}
client_a.disconnect().await.expect("disconnect A");
client_b.disconnect().await.expect("disconnect B");
}
#[tokio::test]
#[ignore]
async fn test_subscription_filters_by_kind() {
let url = relay_url();
let channel = channel_id("filter-kind");
let target_kind: u16 = 40001;
let other_kind: u16 = 40002;
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let sid = sub_id("filter-kind");
let filter = Filter::new()
.kind(Kind::Custom(target_kind))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE");
// Send one matching event and one non-matching event.
let ok_match = client
.send_text_message(&keys, &channel, "should arrive", target_kind)
.await
.expect("send matching");
assert!(ok_match.accepted, "matching event rejected");
let ok_other = client
.send_text_message(&keys, &channel, "should not arrive", other_kind)
.await
.expect("send non-matching");
assert!(ok_other.accepted, "non-matching event rejected");
// We should receive exactly the matching event.
let msg = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv event");
match msg {
RelayMessage::Event { event, .. } => {
assert_eq!(event.content, "should arrive");
assert_eq!(event.kind, Kind::Custom(target_kind));
}
other => panic!("Expected Event, got {other:?}"),
}
// No second event should arrive within a short timeout.
let result = client.recv_event(Duration::from_millis(500)).await;
match result {
Err(TestClientError::Timeout) => { /* expected */ }
Ok(RelayMessage::Event { event, .. }) => {
panic!("Received unexpected event: kind={}", event.kind.as_u16());
}
Ok(other) => {
// EOSE or NOTICE are fine to receive here.
let _ = other;
}
Err(e) => panic!("Unexpected error: {e}"),
}
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_close_subscription_stops_delivery() {
let url = relay_url();
let channel = channel_id("close-sub");
let kind: u16 = 40001;
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let sid = sub_id("close-sub");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE");
client
.close_subscription(&sid)
.await
.expect("close subscription");
tokio::time::sleep(Duration::from_millis(100)).await;
let ok = client
.send_text_message(&keys, &channel, "after close", kind)
.await
.expect("send");
assert!(ok.accepted, "event rejected: {}", ok.message);
let result = client.recv_event(Duration::from_millis(500)).await;
match result {
Err(TestClientError::Timeout) => { /* expected — no delivery */ }
Ok(RelayMessage::Event { event, .. }) => {
panic!(
"Received event after subscription closed: {}",
event.content
);
}
Ok(_) => { /* NOTICE etc. are fine */ }
Err(e) => panic!("Unexpected error: {e}"),
}
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_unauthenticated_rejected() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect_unauthenticated(&url)
.await
.expect("connect unauthenticated");
tokio::time::sleep(Duration::from_millis(200)).await;
let result = client
.send_text_message(&keys, "some-channel", "unauthenticated message", 40001)
.await;
match result {
Ok(ok) => {
// Relay may accept the send but reject with OK false.
assert!(
!ok.accepted,
"Relay accepted unauthenticated event — expected rejection"
);
}
Err(TestClientError::ConnectionClosed) => {
// Relay closed the connection — also acceptable.
}
Err(TestClientError::Timeout) => {
// Relay may not respond at all to unauthenticated clients.
// This is acceptable behaviour.
}
Err(e) => panic!("Unexpected error: {e}"),
}
let _ = client.disconnect().await;
}
#[tokio::test]
#[ignore]
async fn test_multiple_concurrent_clients() {
let url = relay_url();
let channel = channel_id("multi-client");
let kind: u16 = 40001;
let keys: Vec<Keys> = (0..3).map(|_| Keys::generate()).collect();
let mut clients: Vec<SproutTestClient> =
futures_util::future::try_join_all(keys.iter().map(|k| SproutTestClient::connect(&url, k)))
.await
.expect("all clients connect");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]);
for (i, client) in clients.iter_mut().enumerate() {
let sid = format!("multi-{i}");
client
.subscribe(&sid, vec![filter.clone()])
.await
.expect("subscribe");
client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE");
}
let content = format!("broadcast-{}", uuid::Uuid::new_v4());
let ok = clients[0]
.send_text_message(&keys[0], &channel, &content, kind)
.await
.expect("send");
assert!(ok.accepted, "event rejected: {}", ok.message);
for (i, client) in clients.iter_mut().enumerate() {
let msg = client
.recv_event(Duration::from_secs(5))
.await
.unwrap_or_else(|e| panic!("client {i} recv failed: {e}"));
match msg {
RelayMessage::Event { event, .. } => {
assert_eq!(event.content, content, "client {i} received wrong content");
}
other => panic!("client {i}: expected Event, got {other:?}"),
}
}
for client in clients {
client.disconnect().await.expect("disconnect");
}
}
/// Historical events must be returned before EOSE.
#[tokio::test]
#[ignore]
async fn test_stored_events_returned_before_eose() {
let url = relay_url();
let channel = channel_id("stored-events");
let kind: u16 = 40001;
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let content = format!("stored-{}", uuid::Uuid::new_v4());
let ok = client
.send_text_message(&keys, &channel, &content, kind)
.await
.expect("send");
assert!(ok.accepted, "event rejected: {}", ok.message);
let sid = sub_id("stored");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect until EOSE");
let found = events.iter().any(|e| e.content == content);
assert!(
found,
"Stored event not returned before EOSE. Got: {events:?}"
);
client.disconnect().await.expect("disconnect");
}
/// Ephemeral events (kind 20000–29999) must be accepted but not persisted.
#[tokio::test]
#[ignore]
async fn test_ephemeral_event_not_stored() {
let url = relay_url();
let channel = channel_id("ephemeral");
let ephemeral_kind: u16 = 20001;
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let ok = client
.send_text_message(&keys, &channel, "ephemeral content", ephemeral_kind)
.await
.expect("send ephemeral");
assert!(
ok.accepted,
"relay rejected ephemeral event: {}",
ok.message
);
let sid = sub_id("ephemeral");
let filter = Filter::new()
.kind(Kind::Custom(ephemeral_kind))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect until EOSE");
assert!(
events.is_empty(),
"Ephemeral event must not be stored. Got: {events:?}"
);
client.disconnect().await.expect("disconnect");
}
/// Kind-22242 AUTH events submitted via EVENT must be rejected.
#[tokio::test]
#[ignore]
async fn test_auth_event_kind_rejected() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let relay_url_parsed: nostr::Url = url.replace("ws://", "http://").parse().unwrap();
let auth_event = nostr::EventBuilder::auth("fake-challenge", relay_url_parsed)
.sign_with_keys(&keys)
.expect("sign");
let ok = client.send_event(auth_event).await.expect("send");
assert!(
!ok.accepted,
"Relay must reject kind-22242 submitted as EVENT"
);
let msg_lower = ok.message.to_lowercase();
assert!(
msg_lower.contains("invalid") || msg_lower.contains("auth"),
"Rejection message should mention 'invalid' or 'auth', got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// NIP-11 max_subscriptions (100) must be enforced; 101st REQ gets CLOSED.
#[tokio::test]
#[ignore]
async fn test_subscription_limit_enforced() {
let url = relay_url();
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
for i in 0..100 {
let sid = format!("limit-sub-{i}");
let filter = Filter::new().kind(Kind::Custom(40001));
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
// Drain EOSE to avoid buffer buildup.
client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE");
}
let overflow_sid = sub_id("overflow");
// Use a kind that no other test writes, so we don't receive stale events.
let filter = Filter::new().kind(Kind::Custom(49999));
client
.subscribe(&overflow_sid, vec![filter])
.await
.expect("send REQ");
// Drain EOSE and stale events from the 100 earlier subscriptions
// until we receive the CLOSED for the overflow subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED (or timeout)");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue, // stale event from earlier subs
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(subscription_id, overflow_sid);
assert!(
message.to_lowercase().contains("too many"),
"Expected 'too many' in CLOSED message, got: {message}"
);
}
other => panic!("Expected CLOSED for overflow subscription, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_nip11_relay_info() {
let ws_url = relay_url();
let http_url = ws_url
.replace("ws://", "http://")
.replace("wss://", "https://");
let info_url = format!("{http_url}/info");
let client = reqwest::Client::new();
let resp = client
.get(&info_url)
.send()
.await
.expect("HTTP GET /info failed");
assert!(
resp.status().is_success(),
"GET /info returned {}",
resp.status()
);
let body: serde_json::Value = resp.json().await.expect("response is not valid JSON");
assert!(body.get("name").is_some(), "Missing 'name' field");
assert!(
body.get("description").is_some(),
"Missing 'description' field"
);
assert!(
body.get("supported_nips").is_some(),
"Missing 'supported_nips' field"
);
assert!(body.get("version").is_some(), "Missing 'version' field");
let limitation = body.get("limitation").expect("Missing 'limitation' field");
assert_eq!(
limitation.get("max_subscriptions").and_then(|v| v.as_u64()),
Some(100),
"limitation.max_subscriptions must be 100"
);
assert!(
limitation
.get("auth_required")
.and_then(|v| v.as_bool())
.is_some(),
"limitation.auth_required must be a boolean"
);
}
/// Events signed by a key other than the authenticated pubkey must be rejected.
#[tokio::test]
#[ignore]
async fn test_pubkey_mismatch_rejected() {
let url = relay_url();
let channel = channel_id("pubkey-mismatch");
let keys_a = Keys::generate();
let keys_b = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys_a)
.await
.expect("connect as keys_a");
let ok = client
.send_text_message(&keys_b, &channel, "impersonation attempt", 40001)
.await
.expect("send");
assert!(
!ok.accepted,
"Relay must reject event signed by a different key than the authenticated pubkey"
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_eose_sent_for_empty_subscription() {
let url = relay_url();
let channel = channel_id("empty-eose");
let kind: u16 = 40001;
let keys = Keys::generate();
let mut client = SproutTestClient::connect(&url, &keys)
.await
.expect("connect");
let sid = sub_id("empty-eose");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()])
.since(nostr::Timestamp::now());
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect until EOSE");
// There should be no stored events (we just created this channel).
assert!(
events.is_empty(),
"Expected no stored events, got: {events:?}"
);
client.disconnect().await.expect("disconnect");
}