Files
cls 9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

2986 lines
99 KiB
Rust
Raw Permalink 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 Buzz 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 base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError};
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag};
use sha2::{Digest, Sha256};
use uuid::Uuid;
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 relay_http_url() -> String {
relay_url()
.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
fn test_owner_keys() -> Keys {
std::env::var("BUZZ_TEST_OWNER_PRIVATE_KEY")
.ok()
.and_then(|secret| Keys::parse(&secret).ok())
.unwrap_or_else(Keys::generate)
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
hex::encode(digest)
}
fn nip98_post_header(keys: &Keys, url: &str, body: &str) -> String {
let event = EventBuilder::new(Kind::Custom(27_235), "")
.tags(vec![
Tag::parse(["u", url]).unwrap(),
Tag::parse(["method", "POST"]).unwrap(),
Tag::parse(["payload", &sha256_hex(body.as_bytes())]).unwrap(),
Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(),
])
.sign_with_keys(keys)
.expect("sign NIP-98 event");
format!(
"Nostr {}",
BASE64.encode(serde_json::to_string(&event).expect("serialize NIP-98 event"))
)
}
async fn e2e_db_pool() -> sqlx::Pool<sqlx::Postgres> {
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await
.expect("connect to e2e Postgres")
}
async fn ensure_test_community(host: &str) -> uuid::Uuid {
let pool = e2e_db_pool().await;
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO communities (id, host) \
VALUES ($1, $2) \
ON CONFLICT (lower(host)) DO NOTHING",
)
.bind(id)
.bind(host)
.execute(&pool)
.await
.unwrap_or_else(|e| panic!("seed community {host}: {e}"));
sqlx::query_scalar("SELECT id FROM communities WHERE lower(host) = lower($1)")
.bind(host)
.fetch_one(&pool)
.await
.unwrap_or_else(|e| panic!("lookup community {host}: {e}"))
}
async fn seed_relay_member(host: &str, keys: &Keys, role: &str) {
let pool = e2e_db_pool().await;
let community_id = ensure_test_community(host).await;
sqlx::query(
"INSERT INTO relay_members (community_id, pubkey, role, added_by) \
VALUES ($1, $2, $3, NULL) \
ON CONFLICT (community_id, pubkey) DO UPDATE \
SET role = $3, updated_at = now()",
)
.bind(community_id)
.bind(keys.public_key().to_hex())
.bind(role)
.execute(&pool)
.await
.unwrap_or_else(|e| panic!("seed relay member {role}: {e}"));
}
async fn seed_relay_owner(keys: &Keys) {
seed_relay_member("localhost:3000", keys, "owner").await;
}
fn http_origin_for_host(host: &str) -> String {
let scheme = if relay_http_url().starts_with("https://") {
"https"
} else {
"http"
};
format!("{scheme}://{host}")
}
async fn invite_post(keys: &Keys, path: &str, body: &str) -> reqwest::Response {
invite_post_with_host(keys, None, path, body).await
}
async fn invite_post_with_host(
keys: &Keys,
host: Option<&str>,
path: &str,
body: &str,
) -> reqwest::Response {
let client = reqwest::Client::new();
let connection_url = format!("{}{}", relay_http_url(), path);
let signed_url = host
.map(|host| format!("{}{}", http_origin_for_host(host), path))
.unwrap_or_else(|| connection_url.clone());
let mut request = client
.post(&connection_url)
.header("Authorization", nip98_post_header(keys, &signed_url, body))
.header("Content-Type", "application/json");
if let Some(host) = host {
request = request.header(reqwest::header::HOST, host);
}
request
.body(body.to_string())
.send()
.await
.unwrap_or_else(|e| panic!("POST {path} failed: {e}"))
}
/// Create a real channel via a signed kind:9007 event submitted to POST /events.
async fn create_test_channel(keys: &Keys) -> String {
let client = reqwest::Client::new();
let pubkey_hex = keys.public_key().to_hex();
let channel_uuid = uuid::Uuid::new_v4();
let channel_name = format!("relay-e2e-{}", channel_uuid);
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
Tag::parse(["name", &channel_name]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "open"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
let resp = client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&event).unwrap())
.send()
.await
.expect("submit create-channel event");
assert!(
resp.status().is_success(),
"channel creation event failed: {}",
resp.status()
);
let body: serde_json::Value = resp.json().await.expect("parse event response");
assert!(
body["accepted"].as_bool().unwrap_or(false),
"channel creation not accepted: {}",
body
);
channel_uuid.to_string()
}
#[tokio::test]
#[ignore]
async fn test_connect_and_authenticate() {
let url = relay_url();
let keys = Keys::generate();
let client = BuzzTestClient::connect(&url, &keys)
.await
.expect("should connect and authenticate");
client.disconnect().await.expect("clean disconnect");
}
#[tokio::test]
#[ignore]
async fn test_client_submitted_nip43_membership_snapshots_are_rejected() {
let url = relay_url();
let keys = Keys::generate();
// Prove this actor can submit a normal event so the rejection below is
// specifically the relay-only invariant, not a broader authorization failure.
create_test_channel(&keys).await;
let forged = EventBuilder::new(Kind::Custom(13_534), "")
.tags([Tag::parse(["member", &keys.public_key().to_hex(), "owner"]).unwrap()])
.sign_with_keys(&keys)
.expect("sign forged membership snapshot");
let mut ws = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = ws
.send_event(forged.clone())
.await
.expect("submit forged snapshot via websocket");
assert!(!ok.accepted, "forged WebSocket snapshot must be rejected");
assert_eq!(ok.message, "restricted: relay-only kind");
ws.disconnect().await.expect("disconnect");
let response = reqwest::Client::new()
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", keys.public_key().to_hex())
.header("Content-Type", "application/json")
.body(serde_json::to_string(&forged).unwrap())
.send()
.await
.expect("submit forged snapshot via HTTP");
assert_eq!(response.status(), reqwest::StatusCode::BAD_REQUEST);
let body = response.text().await.expect("read HTTP rejection");
assert!(
body.contains("restricted: relay-only kind"),
"unexpected HTTP rejection: {body}"
);
}
#[tokio::test]
#[ignore]
async fn test_invite_mint_and_claim_admits_new_pubkey() {
let owner = test_owner_keys();
let joiner = Keys::generate();
seed_relay_owner(&owner).await;
let mint_response = invite_post(&owner, "/api/invites", "{}").await;
assert_eq!(mint_response.status(), reqwest::StatusCode::OK);
let mint_json: serde_json::Value = mint_response.json().await.expect("mint JSON");
let code = mint_json
.get("code")
.and_then(serde_json::Value::as_str)
.expect("mint response includes code");
assert_eq!(
mint_json.get("url").and_then(serde_json::Value::as_str),
Some(format!("{}/invite/{code}", relay_http_url()).as_str()),
"minted URL should be the shareable HTTPS/HTTP invite URL"
);
let claim_body = serde_json::json!({ "code": code }).to_string();
let claim_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await;
assert_eq!(claim_response.status(), reqwest::StatusCode::OK);
let claim_json: serde_json::Value = claim_response.json().await.expect("claim JSON");
assert_eq!(
claim_json.get("status").and_then(serde_json::Value::as_str),
Some("joined")
);
assert_eq!(
claim_json.get("role").and_then(serde_json::Value::as_str),
Some("member")
);
let repeat_response = invite_post(&joiner, "/api/invites/claim", &claim_body).await;
assert_eq!(repeat_response.status(), reqwest::StatusCode::OK);
let repeat_json: serde_json::Value = repeat_response.json().await.expect("repeat claim JSON");
assert_eq!(
repeat_json
.get("status")
.and_then(serde_json::Value::as_str),
Some("already_member")
);
}
#[tokio::test]
#[ignore]
async fn test_invite_claim_rejects_invalid_code() {
let joiner = Keys::generate();
let body = serde_json::json!({ "code": "garbage.code" }).to_string();
let response = invite_post(&joiner, "/api/invites/claim", &body).await;
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
let json: serde_json::Value = response.json().await.expect("error JSON");
assert_eq!(
json.get("error").and_then(serde_json::Value::as_str),
Some("invite_invalid")
);
}
#[tokio::test]
#[ignore]
async fn test_invite_mint_requires_owner_or_admin() {
let member = Keys::generate();
seed_relay_member("localhost:3000", &member, "member").await;
let response = invite_post(&member, "/api/invites", "{}").await;
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
let outsider = Keys::generate();
let response = invite_post(&outsider, "/api/invites", "{}").await;
assert_eq!(response.status(), reqwest::StatusCode::FORBIDDEN);
}
#[tokio::test]
#[ignore]
async fn test_invite_code_minted_for_one_host_fails_on_another() {
let host_a = format!("invites-a-{}.example", Uuid::new_v4().simple());
let host_b = format!("invites-b-{}.example", Uuid::new_v4().simple());
let owner = Keys::generate();
let joiner = Keys::generate();
ensure_test_community(&host_b).await;
seed_relay_member(&host_a, &owner, "owner").await;
let mint_response = invite_post_with_host(&owner, Some(&host_a), "/api/invites", "{}").await;
assert_eq!(mint_response.status(), reqwest::StatusCode::OK);
let mint_json: serde_json::Value = mint_response.json().await.expect("mint JSON");
let code = mint_json
.get("code")
.and_then(serde_json::Value::as_str)
.expect("mint response includes code");
let claim_body = serde_json::json!({ "code": code }).to_string();
let claim_response =
invite_post_with_host(&joiner, Some(&host_b), "/api/invites/claim", &claim_body).await;
assert_eq!(claim_response.status(), reqwest::StatusCode::FORBIDDEN);
}
#[tokio::test]
#[ignore]
async fn test_send_event_and_receive_via_subscription() {
let url = relay_url();
let kind: u16 = 9;
let keys_a = Keys::generate();
let keys_b = Keys::generate();
let channel = create_test_channel(&keys_a).await;
let mut client_a = BuzzTestClient::connect(&url, &keys_a)
.await
.expect("client A connect");
let sid = sub_id("send-recv");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tags(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 = BuzzTestClient::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_large_event_frame_below_configured_limit_is_accepted() {
let url = relay_url();
let kind: u16 = 9;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let h_tag = Tag::parse(["h", channel.as_str()]).expect("h tag");
let content = "x".repeat(70_000);
let event = EventBuilder::new(Kind::Custom(kind), content)
.tags([h_tag])
.sign_with_keys(&keys)
.expect("sign large event");
let frame = serde_json::to_string(&serde_json::json!(["EVENT", &event])).expect("frame JSON");
assert!(
frame.len() > 65_536,
"test frame must exceed the old 64 KiB cap; got {} bytes",
frame.len()
);
assert!(
frame.len() < 512 * 1024,
"test frame should fit under the new default cap; got {} bytes",
frame.len()
);
let ok = client.send_event(event).await.expect("send large event");
assert!(ok.accepted, "large event rejected: {}", ok.message);
let ok_after = client
.send_text_message(
&keys,
&channel,
"socket still usable after large frame",
kind,
)
.await
.expect("send follow-up event");
assert!(
ok_after.accepted,
"follow-up event rejected: {}",
ok_after.message
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_subscription_filters_by_kind() {
let url = relay_url();
let target_kind: u16 = 9;
let other_kind: u16 = 40002;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let sid = sub_id("filter-kind");
let filter = Filter::new()
.kind(Kind::Custom(target_kind))
.custom_tags(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 kind: u16 = 9;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let sid = sub_id("close-sub");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tags(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 = BuzzTestClient::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", 9)
.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 kind: u16 = 9;
let keys: Vec<Keys> = (0..3).map(|_| Keys::generate()).collect();
let channel = create_test_channel(&keys[0]).await;
let mut clients: Vec<BuzzTestClient> =
futures_util::future::try_join_all(keys.iter().map(|k| BuzzTestClient::connect(&url, k)))
.await
.expect("all clients connect");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tags(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 kind: u16 = 9;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
let mut client = BuzzTestClient::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_tags(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 2000029999) must be accepted but not persisted.
#[tokio::test]
#[ignore]
async fn test_ephemeral_event_not_stored() {
let url = relay_url();
let ephemeral_kind: u16 = 20001;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
let mut client = BuzzTestClient::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_tags(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 = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let relay_url_parsed: nostr::RelayUrl = url.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 must be enforced; (limit+1)th REQ gets CLOSED.
///
/// The relay's MAX_SUBSCRIPTIONS is 1024. Opening 1024 subs in a test is slow,
/// so we open a smaller batch and verify the NIP-11 advertised limit matches
/// the actual enforcement constant. The full-limit test is covered by the
/// NIP-11 assertion below (which verifies the advertised value is 1024).
#[tokio::test]
#[ignore]
async fn test_subscription_limit_enforced() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Open 1024 subscriptions (the relay's MAX_SUBSCRIPTIONS).
for i in 0..1024 {
let sid = format!("limit-sub-{i}");
let filter = Filter::new().kind(Kind::Custom(9));
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(1024),
"limitation.max_subscriptions must be 1024"
);
// The REQ, EVENT, and COUNT handlers unconditionally require an
// authenticated connection, so the NIP-11 doc must advertise that.
assert_eq!(
limitation.get("auth_required").and_then(|v| v.as_bool()),
Some(true),
"limitation.auth_required must be true — REQ/EVENT/COUNT require NIP-42 auth"
);
}
/// 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 keys_a = Keys::generate();
let keys_b = Keys::generate();
let channel = create_test_channel(&keys_a).await;
let mut client = BuzzTestClient::connect(&url, &keys_a)
.await
.expect("connect as keys_a");
let ok = client
.send_text_message(&keys_b, &channel, "impersonation attempt", 9)
.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 kind: u16 = 9;
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let sid = sub_id("empty-eose");
let filter = Filter::new()
.kind(Kind::Custom(kind))
.custom_tags(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");
}
/// Kind:0 NIP-05 sync regression test.
///
/// Verifies:
/// 1. A valid `nip05` in kind:0 content is synced to the profile and resolvable via NIP-05 endpoint.
/// 2. An off-domain `nip05` in kind:0 content is NOT synced (handle is cleared).
#[tokio::test]
#[ignore]
async fn test_kind0_nip05_sync() {
let url = relay_url();
let http = relay_http_url();
let keys = Keys::generate();
let pubkey_hex = keys.public_key().to_hex();
// Extract the relay domain from the relay URL for building a valid NIP-05 handle.
// e.g. "ws://localhost:3000" → "localhost"
let relay_domain = url
.trim_start_matches("wss://")
.trim_start_matches("ws://")
.split(':')
.next()
.unwrap_or("localhost")
.split('/')
.next()
.unwrap_or("localhost")
.to_lowercase();
let unique_name = format!("kind0test{}", &pubkey_hex[..8]);
let valid_handle = format!("{}@{}", unique_name, relay_domain);
// Step 1: Connect and publish kind:0 with a valid nip05 handle.
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let kind0_content = serde_json::json!({
"display_name": "Kind0 Test User",
"nip05": valid_handle,
})
.to_string();
let event = nostr::EventBuilder::new(Kind::Custom(0), kind0_content)
.tags([])
.sign_with_keys(&keys)
.expect("sign kind:0");
let ok = client.send_event(event).await.expect("send kind:0");
assert!(
ok.accepted,
"kind:0 event should be accepted: {:?}",
ok.message
);
// Give the relay a moment to process the side effect.
tokio::time::sleep(Duration::from_millis(300)).await;
// Step 2: Verify the kind:0 content was stored via POST /query.
let http_client = reqwest::Client::new();
let filters = serde_json::json!([{
"kinds": [0],
"authors": [&pubkey_hex],
"limit": 1,
}]);
let profile_resp = http_client
.post(format!("{}/query", http))
.header("X-Pubkey", &pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&filters).unwrap())
.send()
.await
.expect("query kind:0");
assert!(
profile_resp.status().is_success(),
"kind:0 query failed: {}",
profile_resp.status()
);
let events: Vec<serde_json::Value> = profile_resp.json().await.expect("kind:0 json");
assert!(
!events.is_empty(),
"kind:0 event should exist after publishing"
);
let kind0_stored: serde_json::Value =
serde_json::from_str(events[0]["content"].as_str().unwrap_or("{}"))
.expect("parse kind:0 content");
assert_eq!(
kind0_stored["nip05"].as_str(),
Some(valid_handle.as_str()),
"nip05 should be stored in kind:0 content"
);
// Step 3: Verify NIP-05 resolves via /.well-known/nostr.json.
let nip05_resp = http_client
.get(format!(
"{}/.well-known/nostr.json?name={}",
http, unique_name
))
.send()
.await
.expect("GET nostr.json");
assert_eq!(nip05_resp.status(), 200);
let nip05_body: serde_json::Value = nip05_resp.json().await.expect("nip05 json");
let resolved_pubkey = nip05_body["names"][&unique_name].as_str();
assert_eq!(
resolved_pubkey,
Some(pubkey_hex.as_str()),
"NIP-05 should resolve the pubkey after kind:0 sync"
);
// Step 4: Publish another kind:0 with an off-domain nip05 (should be cleared).
// Sleep to ensure a strictly newer created_at (second-level granularity).
tokio::time::sleep(Duration::from_secs(1)).await;
let off_domain_content = serde_json::json!({
"display_name": "Kind0 Test User",
"nip05": format!("{}@evil.com", unique_name),
})
.to_string();
let event2 = nostr::EventBuilder::new(Kind::Custom(0), off_domain_content)
.tags([])
.sign_with_keys(&keys)
.expect("sign kind:0 off-domain");
let ok2 = client
.send_event(event2)
.await
.expect("send kind:0 off-domain");
assert!(
ok2.accepted,
"off-domain kind:0 should still be accepted (stored but handle cleared)"
);
tokio::time::sleep(Duration::from_millis(300)).await;
// Step 5: Verify the handle was CLEARED — NIP-05 should no longer resolve
// after the off-domain kind:0 was accepted. The relay's side effect clears
// the nip05_handle in the users table when the domain doesn't match.
let nip05_resp2 = http_client
.get(format!(
"{}/.well-known/nostr.json?name={}",
http, unique_name
))
.send()
.await
.expect("GET nostr.json after clear");
let nip05_body2: serde_json::Value = nip05_resp2.json().await.expect("nip05 json");
assert!(
nip05_body2["names"][&unique_name].is_null(),
"NIP-05 should not resolve after handle was cleared"
);
client.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000 (PUT_USER): default policy ("anyone") allows a third party to add an agent.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_default_policy_allows() {
let url = relay_url();
let channel_owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Create a channel owned by channel_owner.
let channel_id = create_test_channel(&channel_owner_keys).await;
// Connect as channel_owner.
let mut ws = BuzzTestClient::connect(&url, &channel_owner_keys)
.await
.expect("connect as channel_owner");
// Build kind 9000 PUT_USER event: h = channel_id, p = agent pubkey.
let h_tag = nostr::Tag::parse(["h", &channel_id]).expect("h tag");
let p_tag = nostr::Tag::parse(["p", &agent_pubkey_hex]).expect("p tag");
let event = nostr::EventBuilder::new(Kind::Custom(9000), "")
.tags([h_tag, p_tag])
.sign_with_keys(&channel_owner_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
assert!(
ok.accepted,
"default policy should allow PUT_USER, got: {}",
ok.message
);
ws.disconnect().await.expect("disconnect");
}
/// Restoring an archived channel must re-signal connected members so their
/// agents resubscribe. Archive evicts live channel subscriptions; unarchive
/// emits a kind:44100 member_added notification per member on the always-live
/// global membership feed, which is the resubscribe trigger remove/re-add uses.
#[tokio::test]
#[ignore]
async fn test_unarchive_emits_member_added_notification() {
let url = relay_url();
let owner_keys = Keys::generate();
let owner_pubkey_hex = owner_keys.public_key().to_hex();
// Creating the channel makes the owner its sole member.
let channel_id = create_test_channel(&owner_keys).await;
let mut ws = BuzzTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
// Subscribe to the global membership feed (kind:44100 addressed to the owner).
// This is a global, non-channel-scoped subscription, so archive's
// channel-scoped eviction leaves it intact across the archive→unarchive cycle.
let sid = sub_id("membership-feed");
let membership_filter = Filter::new().kind(Kind::Custom(44100)).custom_tags(
SingleLetterTag::lowercase(Alphabet::P),
[owner_pubkey_hex.as_str()],
);
ws.subscribe(&sid, vec![membership_filter])
.await
.expect("subscribe to membership feed");
ws.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("membership feed EOSE");
// Archive, then unarchive, the channel via kind:9002 edit-metadata.
for archived in ["true", "false"] {
let event = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["archived", archived]).unwrap(),
])
.sign_with_keys(&owner_keys)
.unwrap();
let ok = ws.send_event(event).await.expect("send kind 9002");
assert!(ok.accepted, "edit-metadata rejected: {}", ok.message);
}
// The unarchive must fan out a 44100 to the owner. Loop past any other
// events delivered on the connection until we see it (or time out).
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
loop {
let remaining = deadline
.checked_duration_since(tokio::time::Instant::now())
.filter(|d| !d.is_zero())
.expect("timed out waiting for member_added notification");
if let RelayMessage::Event { event, .. } = ws
.recv_event(remaining)
.await
.expect("recv membership notification")
{
if event.kind == Kind::Custom(44100) {
let content: serde_json::Value =
serde_json::from_str(&event.content).expect("parse notification content");
assert_eq!(content["type"], "member_added");
assert_eq!(content["channel_id"], channel_id);
break;
}
}
}
ws.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000 (PUT_USER): "nobody" policy blocks a third party from adding the agent.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_nobody_blocks() {
let url = relay_url();
let channel_owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Set agent's channel_add_policy to "nobody" via kind:10100 event.
let http_client = reqwest::Client::new();
let policy_event = EventBuilder::new(
Kind::Custom(10100),
serde_json::json!({ "channel_add_policy": "nobody" }).to_string(),
)
.sign_with_keys(&agent_keys)
.expect("sign kind:10100");
let resp = http_client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &agent_pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&policy_event).unwrap())
.send()
.await
.expect("set policy request");
assert!(
resp.status().is_success(),
"set policy failed: {}",
resp.status()
);
// Create a channel owned by channel_owner (not the agent).
let channel_id = create_test_channel(&channel_owner_keys).await;
// Connect as channel_owner.
let mut ws = BuzzTestClient::connect(&url, &channel_owner_keys)
.await
.expect("connect as channel_owner");
// Build kind 9000 PUT_USER event targeting the agent.
let h_tag = nostr::Tag::parse(["h", &channel_id]).expect("h tag");
let p_tag = nostr::Tag::parse(["p", &agent_pubkey_hex]).expect("p tag");
let event = nostr::EventBuilder::new(Kind::Custom(9000), "")
.tags([h_tag, p_tag])
.sign_with_keys(&channel_owner_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
assert!(
!ok.accepted,
"nobody policy should block PUT_USER, but relay accepted it"
);
assert!(
ok.message.contains("policy:nobody"),
"rejection message should contain 'policy:nobody', got: {}",
ok.message
);
ws.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000 (PUT_USER): self-add bypasses "nobody" policy — an agent can always add itself.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_self_add_bypasses_policy() {
let url = relay_url();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Set agent's channel_add_policy to "nobody" via kind:10100 event.
let http_client = reqwest::Client::new();
let policy_event = EventBuilder::new(
Kind::Custom(10100),
serde_json::json!({ "channel_add_policy": "nobody" }).to_string(),
)
.sign_with_keys(&agent_keys)
.expect("sign kind:10100");
let resp = http_client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &agent_pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&policy_event).unwrap())
.send()
.await
.expect("set policy request");
assert!(
resp.status().is_success(),
"set policy failed: {}",
resp.status()
);
// Create a channel where the agent is the owner.
let channel_id = create_test_channel(&agent_keys).await;
// Connect as agent.
let mut ws = BuzzTestClient::connect(&url, &agent_keys)
.await
.expect("connect as agent");
// Build kind 9000 PUT_USER event where agent targets ITSELF.
let h_tag = nostr::Tag::parse(["h", &channel_id]).expect("h tag");
let p_tag = nostr::Tag::parse(["p", &agent_pubkey_hex]).expect("p tag");
let event = nostr::EventBuilder::new(Kind::Custom(9000), "")
.allow_self_tagging()
.tags([h_tag, p_tag])
.sign_with_keys(&agent_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
assert!(
ok.accepted,
"self-add should bypass nobody policy, got: {}",
ok.message
);
ws.disconnect().await.expect("disconnect");
}
/// NIP-29 kind 9000: `owner_only` policy blocks third-party PUT_USER.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_owner_only_blocks() {
let url = relay_url();
let channel_owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Set agent's channel_add_policy to "owner_only" via kind:10100 event.
let http_client = reqwest::Client::new();
let policy_event = EventBuilder::new(
Kind::Custom(10100),
serde_json::json!({ "channel_add_policy": "owner_only" }).to_string(),
)
.sign_with_keys(&agent_keys)
.expect("sign kind:10100");
let resp = http_client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &agent_pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&policy_event).unwrap())
.send()
.await
.expect("set policy request");
assert!(
resp.status().is_success(),
"set policy failed: {}",
resp.status()
);
// Create a channel owned by channel_owner (not the agent).
let channel_id = create_test_channel(&channel_owner_keys).await;
// Connect as channel_owner.
let mut ws = BuzzTestClient::connect(&url, &channel_owner_keys)
.await
.expect("connect as channel_owner");
// Build kind 9000 PUT_USER event targeting the agent.
let h_tag = nostr::Tag::parse(["h", &channel_id]).expect("h tag");
let p_tag = nostr::Tag::parse(["p", &agent_pubkey_hex]).expect("p tag");
let event = nostr::EventBuilder::new(Kind::Custom(9000), "")
.tags([h_tag, p_tag])
.sign_with_keys(&channel_owner_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
assert!(
!ok.accepted,
"owner_only policy should block third-party PUT_USER, but relay accepted it"
);
assert!(
ok.message.contains("policy:owner_only"),
"rejection message should contain 'policy:owner_only', got: {}",
ok.message
);
ws.disconnect().await.expect("disconnect");
}
/// End-to-end test of the standard NIP-29 client flow:
/// connect, authenticate, discover groups, subscribe, send/receive messages,
/// react, and delete.
#[tokio::test]
#[ignore]
async fn test_nip29_standard_client_flow() {
let url = relay_url();
let keys = Keys::generate();
let channel_id = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys)
.await
.expect("connect and authenticate via NIP-42");
// 1. Query group discovery events (kind:39000)
// The channel was just created, so the relay should have emitted a 39000 event.
let discovery_sid = sub_id("discovery");
let discovery_filter = Filter::new().kind(Kind::Custom(39000));
client
.subscribe(&discovery_sid, vec![discovery_filter])
.await
.expect("subscribe to group discovery");
let discovery_events = client
.collect_until_eose(&discovery_sid, Duration::from_secs(5))
.await
.expect("collect discovery events");
// Find our channel's 39000 event by checking d tags.
let our_group = discovery_events.iter().find(|e| {
e.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "d" && s[1] == channel_id
})
});
assert!(
our_group.is_some(),
"should find kind:39000 for our channel among {} events",
discovery_events.len()
);
let group_meta = our_group.unwrap();
// Verify it has a name tag.
let has_name = group_meta.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "name"
});
assert!(has_name, "39000 event should have a name tag");
// 1b. Verify kind:39001 (group admins) was also emitted.
let admins_sid = sub_id("admins");
let admins_filter = Filter::new().kind(Kind::Custom(39001));
client
.subscribe(&admins_sid, vec![admins_filter])
.await
.expect("subscribe to group admins");
let admins_events = client
.collect_until_eose(&admins_sid, Duration::from_secs(5))
.await
.expect("collect admins events");
let our_admins = admins_events.iter().find(|e| {
e.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "d" && s[1] == channel_id
})
});
assert!(
our_admins.is_some(),
"should find kind:39001 for our channel among {} events",
admins_events.len()
);
// 1c. Verify kind:39002 (group members) was also emitted.
let members_sid = sub_id("members");
let members_filter = Filter::new().kind(Kind::Custom(39002));
client
.subscribe(&members_sid, vec![members_filter])
.await
.expect("subscribe to group members");
let members_events = client
.collect_until_eose(&members_sid, Duration::from_secs(5))
.await
.expect("collect members events");
let our_members = members_events.iter().find(|e| {
e.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "d" && s[1] == channel_id
})
});
assert!(
our_members.is_some(),
"should find kind:39002 for our channel among {} events",
members_events.len()
);
// 2. Subscribe to channel messages (kind:9 + h tag).
let msg_sid = sub_id("messages");
let msg_filter = Filter::new()
.kind(Kind::Custom(9))
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), channel_id.as_str());
client
.subscribe(&msg_sid, vec![msg_filter])
.await
.expect("subscribe to channel messages");
let _historical = client
.collect_until_eose(&msg_sid, Duration::from_secs(5))
.await
.expect("collect historical messages");
// 3. Send a kind:9 message with h tag.
let content = format!("nip29-test-{}", uuid::Uuid::new_v4());
let ok = client
.send_text_message(&keys, &channel_id, &content, 9)
.await
.expect("send kind:9 message");
assert!(
ok.accepted,
"relay should accept kind:9 with h tag: {}",
ok.message
);
// 4. Receive the message on the subscription and capture the event ID.
let msg = client
.recv_event(Duration::from_secs(5))
.await
.expect("receive kind:9 event");
let message_event_id = match msg {
RelayMessage::Event { ref event, .. } => {
assert_eq!(event.kind, Kind::Custom(9));
assert_eq!(event.content, content);
event.id.to_hex()
}
other => panic!("expected EVENT, got: {:?}", other),
};
// 5. Send a kind:7 reaction targeting the message.
let h_tag = Tag::parse(["h", &channel_id]).expect("h tag");
let e_tag = Tag::parse(["e", &message_event_id]).expect("e tag");
let reaction_event = EventBuilder::new(Kind::Custom(7), "+")
.tags([h_tag, e_tag])
.sign_with_keys(&keys)
.expect("sign reaction");
let ok = client
.send_event(reaction_event)
.await
.expect("send reaction");
assert!(
ok.accepted,
"relay should accept kind:7 reaction: {}",
ok.message
);
// 6. Send a kind:5 deletion targeting the message.
let h_tag2 = Tag::parse(["h", &channel_id]).expect("h tag");
let e_tag2 = Tag::parse(["e", &message_event_id]).expect("e tag");
let delete_event = EventBuilder::new(Kind::Custom(5), "test delete")
.tags([h_tag2, e_tag2])
.sign_with_keys(&keys)
.expect("sign deletion");
let ok = client
.send_event(delete_event)
.await
.expect("send deletion");
assert!(
ok.accepted,
"relay should accept kind:5 deletion: {}",
ok.message
);
// 7. Verify kind:9 without h tag is rejected.
let no_h_event = EventBuilder::new(Kind::Custom(9), "no h tag")
.tags([])
.sign_with_keys(&keys)
.expect("sign no-h event");
let ok = client
.send_event(no_h_event)
.await
.expect("send no-h event");
assert!(!ok.accepted, "relay should reject kind:9 without h tag");
client.disconnect().await.expect("clean disconnect");
}
/// Client-submitted kind:44100 (member-added notification) must be rejected.
/// Only the relay keypair may sign these events.
#[tokio::test]
#[ignore]
async fn test_membership_notification_kind_rejected() {
let url = relay_url();
let keys = Keys::generate();
let channel_id = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let p_tag = Tag::parse(["p", &keys.public_key().to_hex()]).expect("p tag");
let h_tag = Tag::parse(["h", &channel_id]).expect("h tag");
let event = EventBuilder::new(Kind::Custom(44100), "")
.tags([p_tag, h_tag])
.sign_with_keys(&keys)
.expect("sign kind:44100");
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay must reject client-submitted kind:44100, but accepted it"
);
let msg_lower = ok.message.to_lowercase();
assert!(
msg_lower.contains("relay-signed only")
|| msg_lower.contains("relay signed only")
|| msg_lower.contains("relay"),
"rejection message should mention relay-signed restriction, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// When a member is added via REST, the relay must emit a kind:44100 notification
/// to any subscriber filtering on `#p` = that member's pubkey.
#[tokio::test]
#[ignore]
async fn test_membership_notification_emitted_on_add() {
let url = relay_url();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Connect as agent — NIP-42 auth establishes the authenticated pubkey.
let mut agent_client = BuzzTestClient::connect(&url, &agent_keys)
.await
.expect("connect as agent");
// Create a channel owned by owner (not agent).
let channel_id = create_test_channel(&owner_keys).await;
// Subscribe to membership notifications for agent's own pubkey.
let sid = sub_id("membership-notif");
let filter = Filter::new()
.kinds(vec![Kind::Custom(44100), Kind::Custom(44101)])
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
agent_pubkey_hex.as_str(),
)
.since(nostr::Timestamp::now() - 5u64);
agent_client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe to membership notifications");
// Drain EOSE — no historical events expected.
agent_client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE for membership sub");
// Add agent to the channel via signed kind:9000 event.
let http_client = reqwest::Client::new();
let add_event = EventBuilder::new(Kind::Custom(9000), "")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &agent_pubkey_hex]).unwrap(),
])
.sign_with_keys(&owner_keys)
.unwrap();
let resp = http_client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &owner_keys.public_key().to_hex())
.header("Content-Type", "application/json")
.body(serde_json::to_string(&add_event).unwrap())
.send()
.await
.expect("submit add-member event");
assert!(
resp.status().is_success(),
"add member failed: {}",
resp.status()
);
// Wait for the kind:44100 notification.
let msg = agent_client
.recv_event(Duration::from_secs(5))
.await
.expect("recv kind:44100 notification");
match msg {
RelayMessage::Event { event, .. } => {
assert_eq!(
event.kind,
Kind::Custom(44100),
"expected kind:44100, got {}",
event.kind.as_u16()
);
let tags: Vec<Vec<String>> = event
.tags
.iter()
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
.collect();
let has_p = tags
.iter()
.any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_pubkey_hex);
assert!(
has_p,
"kind:44100 missing p tag = agent pubkey. tags: {tags:?}"
);
let has_h = tags
.iter()
.any(|t| t.len() >= 2 && t[0] == "h" && t[1] == channel_id);
assert!(
has_h,
"kind:44100 missing h tag = channel uuid. tags: {tags:?}"
);
}
other => panic!("expected EVENT kind:44100, got {other:?}"),
}
agent_client.disconnect().await.expect("disconnect");
}
/// Subscribing to kind:44100/44101 without a `#p` filter must be rejected with CLOSED.
#[tokio::test]
#[ignore]
async fn test_membership_notification_requires_p_filter() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let sid = sub_id("no-p-filter");
let filter = Filter::new().kinds(vec![Kind::Custom(44100), Kind::Custom(44101)]);
client
.subscribe(&sid, vec![filter])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// A subscription with NO kinds filter and NO #p filter (wildcard) must be rejected with CLOSED
/// because it can match kind:44100/44101.
#[tokio::test]
#[ignore]
async fn test_membership_notification_wildcard_filter_rejected() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let sid = sub_id("wildcard-filter");
// Empty filter — no kinds, no #p — can match kind:44100/44101.
let filter = Filter::new();
client
.subscribe(&sid, vec![filter])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// Subscribing to kind:44100/44101 with someone else's `#p` must be rejected with CLOSED.
#[tokio::test]
#[ignore]
async fn test_membership_notification_requires_own_p_filter() {
let url = relay_url();
let keys_a = Keys::generate();
let keys_b = Keys::generate();
let keys_b_pubkey_hex = keys_b.public_key().to_hex();
// Connect as keys_a.
let mut client = BuzzTestClient::connect(&url, &keys_a)
.await
.expect("connect as keys_a");
let sid = sub_id("wrong-p-filter");
// Filter uses keys_b's pubkey — not the authenticated pubkey (keys_a).
let filter = Filter::new()
.kinds(vec![Kind::Custom(44100), Kind::Custom(44101)])
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
keys_b_pubkey_hex.as_str(),
);
client
.subscribe(&sid, vec![filter])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// When a member is removed via REST, the relay must emit a kind:44101 notification
/// to any subscriber filtering on `#p` = that member's pubkey.
#[tokio::test]
#[ignore]
async fn test_membership_notification_emitted_on_remove() {
let url = relay_url();
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let agent_pubkey_hex = agent_keys.public_key().to_hex();
// Connect as agent — NIP-42 auth establishes the authenticated pubkey.
let mut agent_client = BuzzTestClient::connect(&url, &agent_keys)
.await
.expect("connect as agent");
// Create a channel owned by owner (not agent).
let channel_id = create_test_channel(&owner_keys).await;
// Subscribe to membership notifications for agent's own pubkey.
let sid = sub_id("membership-remove-notif");
let filter = Filter::new()
.kinds(vec![Kind::Custom(44100), Kind::Custom(44101)])
.custom_tag(
SingleLetterTag::lowercase(Alphabet::P),
agent_pubkey_hex.as_str(),
)
.since(nostr::Timestamp::now() - 5u64);
agent_client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe to membership notifications");
// Drain EOSE — no historical events expected.
agent_client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("EOSE for membership sub");
let http_client = reqwest::Client::new();
let owner_pubkey_hex = owner_keys.public_key().to_hex();
// Add agent to the channel via signed kind:9000 event.
let add_event = EventBuilder::new(Kind::Custom(9000), "")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &agent_pubkey_hex]).unwrap(),
])
.sign_with_keys(&owner_keys)
.unwrap();
let resp = http_client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &owner_pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&add_event).unwrap())
.send()
.await
.expect("submit add-member event");
assert!(
resp.status().is_success(),
"add member failed: {}",
resp.status()
);
// Consume the kind:44100 add notification before waiting for the remove.
let add_msg = agent_client
.recv_event(Duration::from_secs(5))
.await
.expect("recv kind:44100 notification");
match add_msg {
RelayMessage::Event { ref event, .. } => {
assert_eq!(
event.kind,
Kind::Custom(44100),
"expected kind:44100 add notification, got {}",
event.kind.as_u16()
);
}
other => panic!("expected EVENT kind:44100, got {other:?}"),
}
// Remove agent from the channel via signed kind:9001 event.
let remove_event = EventBuilder::new(Kind::Custom(9001), "")
.tags(vec![
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &agent_pubkey_hex]).unwrap(),
])
.sign_with_keys(&owner_keys)
.unwrap();
let resp = http_client
.post(format!("{}/events", relay_http_url()))
.header("X-Pubkey", &owner_pubkey_hex)
.header("Content-Type", "application/json")
.body(serde_json::to_string(&remove_event).unwrap())
.send()
.await
.expect("submit remove-member event");
assert!(
resp.status().is_success(),
"remove member failed: {}",
resp.status()
);
// Wait for the kind:44101 remove notification.
let msg = agent_client
.recv_event(Duration::from_secs(5))
.await
.expect("recv kind:44101 notification");
match msg {
RelayMessage::Event { event, .. } => {
assert_eq!(
event.kind,
Kind::Custom(44101),
"expected kind:44101, got {}",
event.kind.as_u16()
);
let tags: Vec<Vec<String>> = event
.tags
.iter()
.map(|t| t.as_slice().iter().map(|s| s.to_string()).collect())
.collect();
let has_p = tags
.iter()
.any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_pubkey_hex);
assert!(
has_p,
"kind:44101 missing p tag = agent pubkey. tags: {tags:?}"
);
let has_h = tags
.iter()
.any(|t| t.len() >= 2 && t[0] == "h" && t[1] == channel_id);
assert!(
has_h,
"kind:44101 missing h tag = channel uuid. tags: {tags:?}"
);
}
other => panic!("expected EVENT kind:44101, got {other:?}"),
}
agent_client.disconnect().await.expect("disconnect");
}
/// Subscribing to kind:44100/44101 with `#p` containing BOTH the client's own pubkey AND
/// a victim's pubkey must be rejected with CLOSED. All #p values must match the authenticated
/// pubkey — including the victim's key is not allowed.
#[tokio::test]
#[ignore]
async fn test_membership_notification_multi_p_rejected() {
let url = relay_url();
let keys_a = Keys::generate();
let keys_b = Keys::generate();
let keys_a_pubkey_hex = keys_a.public_key().to_hex();
let keys_b_pubkey_hex = keys_b.public_key().to_hex();
// Connect as keys_a.
let mut client = BuzzTestClient::connect(&url, &keys_a)
.await
.expect("connect as keys_a");
let sid = sub_id("multi-p-filter");
// Filter includes keys_a's own pubkey AND keys_b's (victim) pubkey.
// The relay must reject this because not all #p values match the authenticated pubkey.
let filter = Filter::new()
.kinds(vec![Kind::Custom(44100), Kind::Custom(44101)])
.custom_tags(
SingleLetterTag::lowercase(Alphabet::P),
[keys_a_pubkey_hex.as_str(), keys_b_pubkey_hex.as_str()],
);
client
.subscribe(&sid, vec![filter])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// A mixed-filter subscription where one filter has `#h` + membership kinds and another
/// filter makes the subscription globally scoped must be rejected with CLOSED.
/// This prevents bypassing the #p requirement via mixed filters.
#[tokio::test]
#[ignore]
async fn test_membership_notification_mixed_filter_rejected() {
let url = relay_url();
let keys = Keys::generate();
let channel_id = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let sid = sub_id("mixed-filter");
// Filter 1: has #h + membership kinds (would skip per-filter #h check)
let filter1 = Filter::new()
.kinds(vec![Kind::Custom(44100)])
.custom_tag(SingleLetterTag::lowercase(Alphabet::H), channel_id.as_str());
// Filter 2: global filter (no #h) — makes the subscription globally scoped.
// No kinds = wildcard, no #p = should trigger rejection.
let filter2 = Filter::new().authors(vec![keys.public_key()]);
client
.subscribe(&sid, vec![filter1, filter2])
.await
.expect("send REQ");
// Drain until we get the CLOSED for our subscription.
let msg = loop {
let m = client
.recv_event(Duration::from_secs(5))
.await
.expect("recv CLOSED");
match &m {
RelayMessage::Eose { .. } => continue,
RelayMessage::Event { .. } => continue,
_ => break m,
}
};
match msg {
RelayMessage::Closed {
subscription_id,
message,
} => {
assert_eq!(
subscription_id, sid,
"CLOSED for wrong subscription: {subscription_id}"
);
assert!(
message.to_lowercase().contains("restricted"),
"expected 'restricted' in CLOSED message, got: {message}"
);
}
other => panic!("expected CLOSED, got {other:?}"),
}
client.disconnect().await.expect("disconnect");
}
/// Create a private channel over WebSocket and return the channel UUID.
async fn create_private_channel_ws(client: &mut BuzzTestClient, keys: &Keys) -> String {
let channel_uuid = uuid::Uuid::new_v4().to_string();
let channel_name = format!("relay-e2e-private-{}", channel_uuid);
let event = EventBuilder::new(Kind::Custom(9007), "")
.tags(vec![
Tag::parse(["h", &channel_uuid]).unwrap(),
Tag::parse(["name", &channel_name]).unwrap(),
Tag::parse(["channel_type", "stream"]).unwrap(),
Tag::parse(["visibility", "private"]).unwrap(),
])
.sign_with_keys(keys)
.unwrap();
let ok = client
.send_event(event)
.await
.expect("create private channel");
assert!(
ok.accepted,
"private channel creation failed: {}",
ok.message
);
channel_uuid
}
/// Submit a kind:9000 PUT_USER event over WebSocket.
///
/// `allow_self_tagging` keeps self-targeted adds working: EventBuilder otherwise
/// drops a `p` tag matching the signer (nostr-0.44.3 builder.rs:435-449) and the
/// event fails as "missing p tag" instead of exercising the authority check.
async fn add_member_ws(
client: &mut BuzzTestClient,
channel_id: &str,
target_pubkey_hex: &str,
signer: &Keys,
) -> (bool, String) {
let h_tag = Tag::parse(["h", channel_id]).unwrap();
let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap();
let event = EventBuilder::new(Kind::Custom(9000), "")
.allow_self_tagging()
.tags([h_tag, p_tag])
.sign_with_keys(signer)
.unwrap();
let ok = client.send_event(event).await.expect("send PUT_USER event");
(ok.accepted, ok.message)
}
/// Submit a kind:9000 PUT_USER event with a role tag over WebSocket.
///
/// See [`add_member_ws`] for why `allow_self_tagging` is required.
async fn add_member_with_role_ws(
client: &mut BuzzTestClient,
channel_id: &str,
target_pubkey_hex: &str,
role: &str,
signer: &Keys,
) -> (bool, String) {
let h_tag = Tag::parse(["h", channel_id]).unwrap();
let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap();
let role_tag = Tag::parse(["role", role]).unwrap();
let event = EventBuilder::new(Kind::Custom(9000), "")
.allow_self_tagging()
.tags([h_tag, p_tag, role_tag])
.sign_with_keys(signer)
.unwrap();
let ok = client
.send_event(event)
.await
.expect("send PUT_USER event with role");
(ok.accepted, ok.message)
}
/// Only owners/admins can add another identity to a private channel.
#[tokio::test]
#[ignore]
async fn test_private_channel_member_cannot_invite() {
let url = relay_url();
let owner_keys = Keys::generate();
let member_keys = Keys::generate();
let invitee_keys = Keys::generate();
// Connect as owner and create a private channel.
let mut owner_client = BuzzTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await;
// Owner adds member_keys as a regular member.
let (accepted, msg) = add_member_ws(
&mut owner_client,
&channel_id,
&member_keys.public_key().to_hex(),
&owner_keys,
)
.await;
assert!(accepted, "owner should add member, got: {msg}");
// Connect as the regular member.
let mut member_client = BuzzTestClient::connect(&url, &member_keys)
.await
.expect("connect as member");
// Regular member tries to invite a third user.
let (accepted, msg) = add_member_ws(
&mut member_client,
&channel_id,
&invitee_keys.public_key().to_hex(),
&member_keys,
)
.await;
assert!(
!accepted,
"regular member must not add another private-channel identity: {msg}"
);
assert!(
msg.contains("owners/admins"),
"rejection should name the owner/admin requirement, got: {msg}"
);
// The same member re-adding *themselves* stays idempotent — the huddle
// bot-add and kind:9021 paths depend on a self-targeted PUT_USER working.
let (accepted, msg) = add_member_ws(
&mut member_client,
&channel_id,
&member_keys.public_key().to_hex(),
&member_keys,
)
.await;
assert!(
accepted,
"self-targeted re-add must stay idempotent, got: {msg}"
);
owner_client.disconnect().await.expect("disconnect owner");
member_client.disconnect().await.expect("disconnect member");
}
/// An admin — not just the owner — can still add to a private channel.
#[tokio::test]
#[ignore]
async fn test_private_channel_admin_can_invite() {
let url = relay_url();
let owner_keys = Keys::generate();
let admin_keys = Keys::generate();
let invitee_keys = Keys::generate();
let mut owner_client = BuzzTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await;
let (accepted, msg) = add_member_with_role_ws(
&mut owner_client,
&channel_id,
&admin_keys.public_key().to_hex(),
"admin",
&owner_keys,
)
.await;
assert!(accepted, "owner should add an admin, got: {msg}");
let mut admin_client = BuzzTestClient::connect(&url, &admin_keys)
.await
.expect("connect as admin");
let (accepted, msg) = add_member_ws(
&mut admin_client,
&channel_id,
&invitee_keys.public_key().to_hex(),
&admin_keys,
)
.await;
assert!(
accepted,
"admin should be able to add to a private channel, got: {msg}"
);
owner_client.disconnect().await.expect("disconnect owner");
admin_client.disconnect().await.expect("disconnect admin");
}
/// A non-member cannot invite someone to a private channel.
#[tokio::test]
#[ignore]
async fn test_private_channel_non_member_cannot_invite() {
let url = relay_url();
let owner_keys = Keys::generate();
let outsider_keys = Keys::generate();
let target_keys = Keys::generate();
// Owner creates a private channel.
let mut owner_client = BuzzTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await;
// Connect as outsider (not a member of the channel).
let mut outsider_client = BuzzTestClient::connect(&url, &outsider_keys)
.await
.expect("connect as outsider");
// Outsider tries to add someone — should be rejected.
let (accepted, msg) = add_member_ws(
&mut outsider_client,
&channel_id,
&target_keys.public_key().to_hex(),
&outsider_keys,
)
.await;
assert!(
!accepted,
"non-member should NOT be able to invite to private channel, but it was accepted"
);
assert!(
msg.contains("not authorized") || msg.contains("not a channel member"),
"rejection should mention authorization or membership, got: {msg}"
);
owner_client.disconnect().await.expect("disconnect owner");
outsider_client
.disconnect()
.await
.expect("disconnect outsider");
}
/// Regular members cannot grant elevated roles (owner/admin) in private channels.
#[tokio::test]
#[ignore]
async fn test_private_channel_member_cannot_grant_admin() {
let url = relay_url();
let owner_keys = Keys::generate();
let member_keys = Keys::generate();
let target_keys = Keys::generate();
// Owner creates a private channel and adds a regular member.
let mut owner_client = BuzzTestClient::connect(&url, &owner_keys)
.await
.expect("connect as owner");
let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await;
let (accepted, msg) = add_member_ws(
&mut owner_client,
&channel_id,
&member_keys.public_key().to_hex(),
&owner_keys,
)
.await;
assert!(accepted, "owner should add member, got: {msg}");
// Connect as the regular member.
let mut member_client = BuzzTestClient::connect(&url, &member_keys)
.await
.expect("connect as member");
// Regular member tries to add someone with admin role — should fail.
let (accepted, msg) = add_member_with_role_ws(
&mut member_client,
&channel_id,
&target_keys.public_key().to_hex(),
"admin",
&member_keys,
)
.await;
assert!(
!accepted,
"regular member should NOT grant admin role, but it was accepted"
);
assert!(
msg.contains("elevated")
|| msg.contains("owner")
|| msg.contains("admin")
|| msg.contains("grant"),
"rejection should mention elevated roles, got: {msg}"
);
owner_client.disconnect().await.expect("disconnect owner");
member_client.disconnect().await.expect("disconnect member");
}
/// Live badge counts: every thread mutation pushes a fresh relay-signed
/// kind:39005 recount to channel subscribers — a reply counts up, deleting
/// that reply counts back down — without any window refetch.
#[tokio::test]
#[ignore]
async fn test_reply_ingest_pushes_live_thread_summary() {
let url = relay_url();
let keys = Keys::generate();
let channel = create_test_channel(&keys).await;
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Root message for the thread — built locally so we keep its id.
let root = EventBuilder::new(Kind::Custom(9), "thread root")
.tags([Tag::parse(["h", channel.as_str()]).unwrap()])
.sign_with_keys(&keys)
.expect("sign root");
let root_id = root.id;
let ok = client.send_event(root).await.expect("send root");
assert!(ok.accepted, "root rejected: {}", ok.message);
// Live subscription shaped like the desktop window-store one: channel
// scope with 39005 in kinds.
let sid = sub_id("live-summary");
let filter = Filter::new()
.kind(Kind::Custom(39005))
.custom_tags(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");
async fn recv_summary(client: &mut BuzzTestClient) -> nostr::Event {
loop {
match client
.recv_event(Duration::from_secs(5))
.await
.expect("recv 39005")
{
RelayMessage::Event { event, .. } if event.kind == Kind::Custom(39005) => {
return *event;
}
_ => continue,
}
}
}
// Reply → pushed summary counts up.
let h_tag = Tag::parse(["h", channel.as_str()]).unwrap();
let root_tag = Tag::parse(["e", &root_id.to_hex(), "", "reply"]).unwrap();
let reply = EventBuilder::new(Kind::Custom(9), "first reply")
.tags([h_tag, root_tag])
.sign_with_keys(&keys)
.expect("sign reply");
let reply_id = reply.id;
let ok = client.send_event(reply).await.expect("send reply");
assert!(ok.accepted, "reply rejected: {}", ok.message);
let summary = recv_summary(&mut client).await;
let root_tag_val = summary
.tags
.iter()
.find(|t| t.as_slice().first().map(String::as_str) == Some("e"))
.and_then(|t| t.content().map(str::to_string))
.expect("summary carries root e-tag");
assert_eq!(root_tag_val, root_id.to_hex(), "summary targets the root");
let content: serde_json::Value = serde_json::from_str(&summary.content).expect("JSON");
assert_eq!(content["reply_count"], 1, "reply counted up: {content}");
// Delete the reply → pushed summary counts back down.
let delete = EventBuilder::new(Kind::Custom(5), "")
.tags([
Tag::parse(["e", &reply_id.to_hex()]).unwrap(),
Tag::parse(["h", channel.as_str()]).unwrap(),
])
.sign_with_keys(&keys)
.expect("sign delete");
let ok = client.send_event(delete).await.expect("send delete");
assert!(ok.accepted, "delete rejected: {}", ok.message);
let summary = recv_summary(&mut client).await;
let content: serde_json::Value = serde_json::from_str(&summary.content).expect("JSON");
assert_eq!(content["reply_count"], 0, "reply counted down: {content}");
client.disconnect().await.expect("disconnect");
}
/// Read a member's authoritative role from the relay-signed kind:39002 member
/// list. The relay's own view of membership, not the client's — a kind:9000 can
/// be `accepted` (stored) while its membership side effect fails, so asserting
/// on the OK alone cannot see a broken write.
async fn member_role(url: &str, keys: &Keys, channel_id: &str, pubkey_hex: &str) -> Option<String> {
let mut ws = BuzzTestClient::connect(url, keys).await.expect("connect");
let sid = sub_id("members");
let filter = Filter::new()
.kind(Kind::Custom(39002))
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [channel_id]);
ws.subscribe(&sid, vec![filter])
.await
.expect("subscribe 39002");
let events = ws
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("39002 EOSE");
ws.disconnect().await.ok();
// Latest 39002 wins; find this pubkey's tag. Shape is
// ["p", pubkey, relay_url, role] (side_effects.rs:1058-1062) — role is
// index 3, not 2.
events.iter().max_by_key(|e| e.created_at).and_then(|e| {
e.tags.iter().find_map(|t| {
let p = t.as_slice();
(p.len() >= 4 && p[0] == "p" && p[1] == pubkey_hex).then(|| p[3].clone())
})
})
}
/// SECURITY REPRO (Dawn): can an unprivileged NON-MEMBER demote the owner of an
/// OPEN channel to `member` with a single kind:9000? Asserts the reported
/// vulnerability is FIXED; it fails on vulnerable code.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_cannot_demote_owner() {
let url = relay_url();
let victim_keys = Keys::generate();
let victim_hex = victim_keys.public_key().to_hex();
let attacker_keys = Keys::generate();
// Victim creates an open channel -> victim is seeded as its owner.
let channel_id = create_test_channel(&victim_keys).await;
let before = member_role(&url, &victim_keys, &channel_id, &victim_hex).await;
assert_eq!(
before.as_deref(),
Some("owner"),
"victim must start as owner (got {before:?})"
);
// The attack: attacker (not a member, not the creator) publishes
// kind:9000 { h=channel, p=victim, role=member }.
let mut ws = BuzzTestClient::connect(&url, &attacker_keys)
.await
.expect("connect as attacker");
let event = EventBuilder::new(Kind::Custom(9000), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &victim_hex]).unwrap(),
Tag::parse(["role", "member"]).unwrap(),
])
.sign_with_keys(&attacker_keys)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
ws.disconnect().await.ok();
let after = member_role(&url, &victim_keys, &channel_id, &victim_hex).await;
println!(
"attacker kind:9000 accepted = {} ({})",
ok.accepted, ok.message
);
println!("victim role before = {before:?}, after = {after:?}");
assert_eq!(
after.as_deref(),
Some("owner"),
"PRIVESC: unprivileged non-member demoted the channel owner to {after:?}"
);
assert!(
!ok.accepted,
"unprivileged non-member's role-demotion kind:9000 must be rejected"
);
}
/// SECURITY REPRO (Dawn), follow-on questions the report asserts but does not test:
/// after the owner is demoted, (a) can the attacker promote itself to owner, and
/// (b) can the ex-owner restore its own role? Both must be rejected — which is
/// exactly what makes the demotion unrecoverable over the relay.
#[tokio::test]
#[ignore]
async fn test_nip29_owner_demotion_recovery_paths() {
let url = relay_url();
let victim_keys = Keys::generate();
let victim_hex = victim_keys.public_key().to_hex();
let attacker_keys = Keys::generate();
let attacker_hex = attacker_keys.public_key().to_hex();
let channel_id = create_test_channel(&victim_keys).await;
let put_user = |signer: Keys, target_hex: String, role: &'static str| {
let channel_id = channel_id.clone();
let url = url.clone();
async move {
let mut ws = BuzzTestClient::connect(&url, &signer)
.await
.expect("connect");
// `allow_self_tagging` is REQUIRED: EventBuilder otherwise silently
// drops any `p` tag matching the signer (nostr-0.44.3
// builder.rs:435-449), which would make self-targeted PUT_USER
// events fail as "missing p tag" and mask the real verdict.
let event = EventBuilder::new(Kind::Custom(9000), "")
.allow_self_tagging()
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &target_hex]).unwrap(),
Tag::parse(["role", role]).unwrap(),
])
.sign_with_keys(&signer)
.expect("sign kind 9000");
let ok = ws.send_event(event).await.expect("send kind 9000");
ws.disconnect().await.ok();
ok
}
};
// Step 1: strip the owner.
let demote = put_user(attacker_keys.clone(), victim_hex.clone(), "member").await;
println!(
"1. attacker demotes owner -> accepted={} {}",
demote.accepted, demote.message
);
// Step 2: attacker tries to make itself owner.
let self_promote = put_user(attacker_keys.clone(), attacker_hex.clone(), "owner").await;
println!(
"2. attacker self->owner -> accepted={} {}",
self_promote.accepted, self_promote.message
);
// Step 3: ex-owner tries to restore itself.
let restore = put_user(victim_keys.clone(), victim_hex.clone(), "owner").await;
println!(
"3. ex-owner restores self -> accepted={} {}",
restore.accepted, restore.message
);
// `accepted` only means the event was stored — the membership side effect can
// still fail. Read the authoritative roles back from the relay-signed 39002.
let mut ws = BuzzTestClient::connect(&url, &victim_keys)
.await
.expect("connect");
let sid = sub_id("members-final");
let filter = Filter::new().kind(Kind::Custom(39002)).custom_tags(
SingleLetterTag::lowercase(Alphabet::D),
[channel_id.as_str()],
);
ws.subscribe(&sid, vec![filter])
.await
.expect("subscribe 39002");
let events = ws
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("39002 EOSE");
ws.disconnect().await.ok();
let latest = events.iter().max_by_key(|e| e.created_at).expect("a 39002");
let roles: Vec<(String, String)> = latest
.tags
.iter()
.filter_map(|t| {
let p = t.as_slice();
(p.len() >= 4 && p[0] == "p").then(|| (p[1].clone(), p[3].clone()))
})
.collect();
let role_of = |hex: &str| {
roles
.iter()
.find(|(pk, _)| pk == hex)
.map(|(_, r)| r.clone())
};
println!("FINAL victim role = {:?}", role_of(&victim_hex));
println!("FINAL attacker role = {:?}", role_of(&attacker_hex));
assert_eq!(
role_of(&victim_hex).as_deref(),
Some("owner"),
"owner must retain its role through all three attempts"
);
assert_eq!(
role_of(&attacker_hex),
None,
"attacker must never gain a role"
);
}
/// SECURITY REPRO (Dawn), finding #2: a kind:9000 PUT_USER with NO `role` tag.
///
/// `handle_put_user` used to default an absent role tag to `member`, so a bare
/// self-targeted PUT_USER silently demoted the sender — no attacker required.
/// `test_nip29_put_user_self_add_bypasses_policy` sends exactly this event but
/// asserts only `ok.accepted`, and side-effect failures are logged rather than
/// surfaced (ingest.rs:2460-2467), so an `accepted` assertion structurally
/// cannot observe the demotion. This asserts the resulting STATE instead.
///
/// TWO owners on purpose. With a sole owner the last-owner guard rejects the
/// demotion anyway, so the test would pass even with the role-preservation
/// removed — it would be measuring a different defect's fix. A second owner
/// disarms that guard, leaving the absent-role-tag handling as the only thing
/// that can decide the outcome.
#[tokio::test]
#[ignore]
async fn test_nip29_put_user_without_role_tag_preserves_role() {
let url = relay_url();
let owner_a = Keys::generate();
let owner_b = Keys::generate();
let b_hex = owner_b.public_key().to_hex();
let channel_id = create_test_channel(&owner_a).await;
// owner_a promotes owner_b, so the channel has two owners.
let mut ws = BuzzTestClient::connect(&url, &owner_a)
.await
.expect("connect as owner_a");
let promote = EventBuilder::new(Kind::Custom(9000), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &b_hex]).unwrap(),
Tag::parse(["role", "owner"]).unwrap(),
])
.sign_with_keys(&owner_a)
.expect("sign promote");
let ok = ws.send_event(promote).await.expect("send promote");
ws.disconnect().await.ok();
assert!(ok.accepted, "promote rejected: {}", ok.message);
assert_eq!(
member_role(&url, &owner_a, &channel_id, &b_hex)
.await
.as_deref(),
Some("owner"),
"owner_b must be a second owner before the probe"
);
// The probe: owner_b sends a bare self-targeted PUT_USER — h + p, no `role`.
// `allow_self_tagging` is required or EventBuilder drops the self `p` tag
// (nostr-0.44.3 builder.rs:435-449) and the event fails as "missing p tag".
let mut ws = BuzzTestClient::connect(&url, &owner_b)
.await
.expect("connect as owner_b");
let bare = EventBuilder::new(Kind::Custom(9000), "")
.allow_self_tagging()
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &b_hex]).unwrap(),
])
.sign_with_keys(&owner_b)
.expect("sign bare put_user");
let ok = ws.send_event(bare).await.expect("send bare put_user");
ws.disconnect().await.ok();
assert!(
ok.accepted,
"self-targeted PUT_USER must stay accepted: {}",
ok.message
);
assert_eq!(
member_role(&url, &owner_a, &channel_id, &b_hex)
.await
.as_deref(),
Some("owner"),
"an absent role tag means no role change — it must not demote an owner"
);
}
/// SECURITY (Dawn), relay-layer guard isolation — the *validator*, not the DB.
///
/// The DB guards in `add_member` are what actually stop the privesc, and every
/// other test here asserts the resulting STATE ("the role didn't change").
/// That makes them structurally blind to `validate_admin_event`: stubbing out
/// either relay-side check leaves the whole nip29 suite green, because the DB
/// still refuses the write and the role is still correct. Verified by mutation
/// — the relay returns `accepted:true` and logs `Side effect failed: access
/// denied: ...` while the state assertion happily passes.
///
/// The relay guards earn their keep by giving the client an honest
/// `accepted:false` instead of an OK for an event that silently fails after the
/// fact. So these two tests assert `accepted == false` — the one observable
/// only the validator controls — and each is shaped so exactly one guard can
/// fire.
///
/// Guard under test: "only owners/admins may change an active member's role".
/// TWO owners on purpose, so the last-owner guard cannot fire and take the
/// credit; a plain member targeting a co-owner leaves the actor check as the
/// only thing that can reject.
#[tokio::test]
#[ignore]
async fn test_nip29_relay_rejects_role_change_by_unprivileged_actor() {
let url = relay_url();
let owner_a = Keys::generate();
let owner_b = Keys::generate();
let b_hex = owner_b.public_key().to_hex();
let attacker = Keys::generate();
let attacker_hex = attacker.public_key().to_hex();
let channel_id = create_test_channel(&owner_a).await;
// owner_a promotes owner_b -> the channel has two owners.
let mut ws = BuzzTestClient::connect(&url, &owner_a)
.await
.expect("connect as owner_a");
let promote = EventBuilder::new(Kind::Custom(9000), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &b_hex]).unwrap(),
Tag::parse(["role", "owner"]).unwrap(),
])
.sign_with_keys(&owner_a)
.expect("sign promote");
let ok = ws.send_event(promote).await.expect("send promote");
ws.disconnect().await.ok();
assert!(ok.accepted, "promote rejected: {}", ok.message);
// The attacker joins the open channel as a plain member.
let mut ws = BuzzTestClient::connect(&url, &attacker)
.await
.expect("connect as attacker");
let join = EventBuilder::new(Kind::Custom(9000), "")
.allow_self_tagging()
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &attacker_hex]).unwrap(),
Tag::parse(["role", "member"]).unwrap(),
])
.sign_with_keys(&attacker)
.expect("sign self-join");
let ok = ws.send_event(join).await.expect("send self-join");
ws.disconnect().await.ok();
assert!(ok.accepted, "self-join rejected: {}", ok.message);
assert_eq!(
member_role(&url, &owner_a, &channel_id, &attacker_hex)
.await
.as_deref(),
Some("member"),
"attacker must be an active plain member before the probe"
);
// The probe: a plain member demotes a co-owner. Two owners remain, so only
// the actor-authorization guard can reject this.
let mut ws = BuzzTestClient::connect(&url, &attacker)
.await
.expect("connect as attacker");
let attack = EventBuilder::new(Kind::Custom(9000), "")
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &b_hex]).unwrap(),
Tag::parse(["role", "member"]).unwrap(),
])
.sign_with_keys(&attacker)
.expect("sign attack");
let ok = ws.send_event(attack).await.expect("send attack");
ws.disconnect().await.ok();
println!(
"unprivileged co-owner demotion -> accepted={} {}",
ok.accepted, ok.message
);
assert!(
!ok.accepted,
"the relay validator must reject an unprivileged actor's role change, \
not accept it and let the side effect fail silently"
);
assert_eq!(
member_role(&url, &owner_a, &channel_id, &b_hex)
.await
.as_deref(),
Some("owner"),
"co-owner must keep their role"
);
}
/// SECURITY (Dawn), relay-layer guard isolation — see the test above for why
/// `accepted` rather than state is the assertion that matters here.
///
/// Guard under test: the relay-side last-owner check. The actor is the SOLE
/// owner demoting themselves, so the actor-authorization guard is satisfied
/// (an owner is elevated) and cannot mask the result — only the last-owner
/// check can reject.
#[tokio::test]
#[ignore]
async fn test_nip29_relay_rejects_last_owner_self_demotion() {
let url = relay_url();
let owner = Keys::generate();
let owner_hex = owner.public_key().to_hex();
let channel_id = create_test_channel(&owner).await;
assert_eq!(
member_role(&url, &owner, &channel_id, &owner_hex)
.await
.as_deref(),
Some("owner"),
"creator must be the sole owner before the probe"
);
// The probe: the sole owner demotes themselves. Elevated actor, so the
// actor check passes; the last-owner guard is the only thing left.
let mut ws = BuzzTestClient::connect(&url, &owner)
.await
.expect("connect as owner");
let demote = EventBuilder::new(Kind::Custom(9000), "")
.allow_self_tagging()
.tags([
Tag::parse(["h", &channel_id]).unwrap(),
Tag::parse(["p", &owner_hex]).unwrap(),
Tag::parse(["role", "member"]).unwrap(),
])
.sign_with_keys(&owner)
.expect("sign self-demote");
let ok = ws.send_event(demote).await.expect("send self-demote");
ws.disconnect().await.ok();
println!(
"sole-owner self-demotion -> accepted={} {}",
ok.accepted, ok.message
);
assert!(
!ok.accepted,
"the relay validator must reject demoting the last owner, not accept \
it and let the side effect fail silently"
);
assert_eq!(
member_role(&url, &owner, &channel_id, &owner_hex)
.await
.as_deref(),
Some("owner"),
"the last owner must keep their role"
);
}