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
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2986 lines
99 KiB
Rust
2986 lines
99 KiB
Rust
//! 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 20000–29999) 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"
|
||
);
|
||
}
|