From 8be8f99b31d228f12a668e46219694910d7ea638 Mon Sep 17 00:00:00 2001 From: Duncan Date: Tue, 13 Oct 2026 14:54:48 -0400 Subject: [PATCH] test(relay): cover NIP-09 deletions of kind:44300 versions Moves the shared deletion e2e helpers from e2e_author_only_deletion into tests/support so both suites reuse them, and checks that a deletion of a version needs k=44300, is author-only on every read surface and live, and leaves the version gone. Co-authored-by: Will Pfleger Signed-off-by: Will Pfleger --- .../tests/e2e_author_only_deletion.rs | 217 +---------------- .../tests/e2e_instructions_versions.rs | 136 ++++++++++- .../tests/support/author_only_deletion.rs | 219 ++++++++++++++++++ 3 files changed, 360 insertions(+), 212 deletions(-) create mode 100644 crates/buzz-test-client/tests/support/author_only_deletion.rs diff --git a/crates/buzz-test-client/tests/e2e_author_only_deletion.rs b/crates/buzz-test-client/tests/e2e_author_only_deletion.rs index e820aabd13..34b235cc92 100644 --- a/crates/buzz-test-client/tests/e2e_author_only_deletion.rs +++ b/crates/buzz-test-client/tests/e2e_author_only_deletion.rs @@ -25,40 +25,17 @@ use std::collections::BTreeSet; use std::time::Duration; -use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError}; -use nostr::{EventBuilder, EventId, Filter, Keys, Kind, Tag}; +use buzz_test_client::BuzzTestClient; +use nostr::{EventBuilder, EventId, Filter, Keys, Kind}; use reqwest::Client; use serde_json::Value; +#[path = "support/author_only_deletion.rs"] +mod support; +use support::*; + const KIND_EVENT_REMINDER: u16 = 30300; -fn relay_url() -> String { - std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) -} - -fn relay_http_url() -> String { - relay_url() - .replace("wss://", "https://") - .replace("ws://", "http://") - .trim_end_matches('/') - .to_string() -} - -fn sub_id(name: &str) -> String { - format!("e2e-aod-{name}-{}", uuid::Uuid::new_v4()) -} - -fn http_client() -> Client { - Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .expect("failed to build HTTP client") -} - -fn tag(parts: &[&str]) -> Tag { - Tag::parse(parts.to_vec()).expect("valid tag") -} - fn build_reminder(keys: &Keys) -> nostr::Event { EventBuilder::new( Kind::Custom(KIND_EVENT_REMINDER), @@ -72,42 +49,6 @@ fn build_reminder(keys: &Keys) -> nostr::Event { .unwrap() } -fn build_deletion(keys: &Keys, tags: Vec) -> nostr::Event { - build_deletion_with_reason(keys, tags, "") -} - -fn build_deletion_with_reason(keys: &Keys, tags: Vec, reason: &str) -> nostr::Event { - EventBuilder::new(Kind::EventDeletion, reason) - .tags(tags) - .sign_with_keys(keys) - .unwrap() -} - -async fn submit(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { - let resp = client - .post(format!("{}/events", relay_http_url())) - .header("X-Pubkey", keys.public_key().to_hex()) - .json(event) - .send() - .await - .expect("submit event"); - let ok = resp.status().is_success(); - let body: Value = resp.json().await.expect("parse response"); - if ok { - ( - body["accepted"].as_bool().unwrap_or(false), - body["message"].as_str().unwrap_or("").to_string(), - ) - } else { - (false, body["error"].as_str().unwrap_or("").to_string()) - } -} - -async fn submit_ok(client: &Client, keys: &Keys, event: &nostr::Event) { - let (accepted, msg) = submit(client, keys, event).await; - assert!(accepted, "setup event rejected: {msg}"); -} - /// Store a reminder as `author`, then delete it with a `k`-tagged kind:5 /// whose reason is a unique search term. Returns the reminder and deletion. async fn store_deleted_reminder(client: &Client, author: &Keys) -> (nostr::Event, nostr::Event) { @@ -132,130 +73,6 @@ fn search_term(author: &Keys) -> String { format!("aodreason{}", &author.public_key().to_hex()[..16]) } -/// Filters that each match only the deletion `store_deleted_reminder` left: -/// by kind, mixed kinds, and id. The id filter is the kindless COUNT case; -/// broader kindless filters can match `#p`-gated kinds, so the relay closes -/// them before any read runs. -fn deletion_filters(author: &Keys, deletion: EventId) -> Vec { - vec![ - Filter::new() - .kind(Kind::EventDeletion) - .author(author.public_key()), - Filter::new() - .kinds([Kind::EventDeletion, Kind::TextNote]) - .author(author.public_key()), - Filter::new().id(deletion), - ] -} - -async fn http_query_ids(client: &Client, reader: &Keys, filter: &Filter) -> Vec { - let resp = client - .post(format!("{}/query", relay_http_url())) - .header("X-Pubkey", reader.public_key().to_hex()) - .json(&vec![filter]) - .send() - .await - .expect("query events"); - assert!( - resp.status().is_success(), - "query failed: {}", - resp.status() - ); - resp.json::>() - .await - .expect("parse query response") - .iter() - .filter_map(|e| e["id"].as_str().map(str::to_string)) - .collect() -} - -async fn http_count(client: &Client, reader: &Keys, filter: &Filter) -> u64 { - let resp = client - .post(format!("{}/count", relay_http_url())) - .header("X-Pubkey", reader.public_key().to_hex()) - .json(&vec![filter]) - .send() - .await - .expect("count events"); - assert!( - resp.status().is_success(), - "count failed: {}", - resp.status() - ); - let body: Value = resp.json().await.expect("parse count response"); - body["count"].as_u64().expect("count field") -} - -async fn ws_query_ids(ws: &mut BuzzTestClient, filter: &Filter) -> Vec { - let sid = sub_id("req"); - ws.subscribe(&sid, vec![filter.clone()]).await.expect("REQ"); - let events = ws - .collect_until_eose(&sid, Duration::from_secs(5)) - .await - .expect("EOSE"); - ws.close_subscription(&sid).await.expect("CLOSE"); - events.into_iter().map(|e| e.id).collect() -} - -async fn ws_count(ws: &mut BuzzTestClient, filter: &Filter) -> u64 { - let sid = sub_id("count"); - ws.send_raw(&serde_json::json!(["COUNT", sid, filter])) - .await - .expect("COUNT"); - loop { - match ws - .recv_event(Duration::from_secs(5)) - .await - .expect("COUNT reply") - { - RelayMessage::Count { - subscription_id, - count, - } if subscription_id == sid => return count, - RelayMessage::Closed { - subscription_id, - message, - } if subscription_id == sid => panic!("COUNT closed: {message}"), - _ => {} - } - } -} - -/// Every historical read surface returns the deletion to `reader` iff -/// `visible`: WS REQ, WS COUNT, HTTP `/query` and HTTP `/count`, by author -/// and by id. -async fn assert_deletion_visibility( - reader: &Keys, - author: &Keys, - deletion: EventId, - visible: bool, -) { - let client = http_client(); - let mut ws = BuzzTestClient::connect(&relay_url(), reader) - .await - .expect("connect reader"); - let expected = u64::from(visible); - let mut mismatches = Vec::new(); - for filter in deletion_filters(author, deletion) { - let observed = [ - ("WS REQ", ws_query_ids(&mut ws, &filter).await.len() as u64), - ("WS COUNT", ws_count(&mut ws, &filter).await), - ( - "HTTP /query", - http_query_ids(&client, reader, &filter).await.len() as u64, - ), - ("HTTP /count", http_count(&client, reader, &filter).await), - ]; - for (surface, n) in observed { - if n != expected { - mismatches.push(format!("{surface} {filter:?}: {n}, expected {expected}")); - } - } - } - assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); - ws.disconnect().await.expect("disconnect"); -} - /// Whether the relay's `search_tsv` indexes kind:5 content. The desired /// schema (`schema/schema.sql`, which CI applies) does; an empty database /// initialized through migration 0008 gets a positive allowlist that doesn't. @@ -832,28 +649,6 @@ async fn receives_any(ws: &mut BuzzTestClient, sid: &str) -> bool { !live_event_ids(ws, sid).await.is_empty() } -/// IDs of the events subscription `sid` receives until 2s pass with no -/// message. A dropped connection or a `CLOSED` for `sid` fails the test, so -/// a dead subscription can never pass for one that saw nothing. -async fn live_event_ids(ws: &mut BuzzTestClient, sid: &str) -> Vec { - let mut ids = Vec::new(); - loop { - match ws.recv_event(Duration::from_secs(2)).await { - Ok(RelayMessage::Event { - subscription_id, - event, - }) if subscription_id == sid => ids.push(event.id), - Ok(RelayMessage::Closed { - subscription_id, - message, - }) if subscription_id == sid => panic!("{sid} closed: {message}"), - Ok(_) => {} - Err(TestClientError::Timeout) => return ids, - Err(e) => panic!("{sid} receive failed: {e}"), - } - } -} - /// Deleting a private deletion would store a public event naming it, so the /// author is refused (NIP-09 gives such a deletion no effect), everyone else /// gets the shared denial, and no reader but the author ever sees either. diff --git a/crates/buzz-test-client/tests/e2e_instructions_versions.rs b/crates/buzz-test-client/tests/e2e_instructions_versions.rs index f4ca211c32..ebf4ebb943 100644 --- a/crates/buzz-test-client/tests/e2e_instructions_versions.rs +++ b/crates/buzz-test-client/tests/e2e_instructions_versions.rs @@ -8,7 +8,10 @@ //! and the HTTP bridge query, with the owner as the positive control; //! - exclusion from full-text search, for the author too; //! - per-subject `limit: 1` current-version reads and composite -//! `(until, before_id)` history paging across tied timestamps. +//! `(until, before_id)` history paging across tied timestamps; +//! - NIP-09 deletions of a version: the `k` tag is required, the deletion is +//! as private as the version on every read surface and live, and the +//! version is gone afterwards. //! //! The relay does not decrypt; content here is an opaque placeholder. //! @@ -27,6 +30,9 @@ use buzz_test_client::{BuzzTestClient, RelayMessage}; use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; use serde_json::{json, Value}; +#[path = "support/author_only_deletion.rs"] +mod aod; + const INSTRUCTIONS_VERSION_KIND: u16 = 44300; const MAX_CONTENT_BYTES: usize = 218_548; const CIPHERTEXT: &str = "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; @@ -685,3 +691,131 @@ async fn test_instructions_version_unsearchable_for_author() { ); ws.disconnect().await.expect("disconnect"); } + +/// Publish a version as `owner`, then delete it with a `k`-tagged kind:5. +/// Returns the version and the accepted deletion. +async fn store_deleted_version(owner: &Keys) -> (nostr::Event, nostr::Event) { + let client = aod::http_client(); + let version = agent_version(owner, &new_agent(), now()); + aod::submit_ok(&client, owner, &version).await; + let deletion = aod::build_deletion( + owner, + vec![ + aod::tag(&["e", &version.id.to_hex()]), + aod::tag(&["k", &INSTRUCTIONS_VERSION_KIND.to_string()]), + ], + ); + aod::submit_ok(&client, owner, &deletion).await; + (version, deletion) +} + +/// A deletion of a version reaches its owner and nobody else over WS REQ, +/// `ids` lookup, WS COUNT, HTTP `/query` and HTTP `/count`, and the version +/// itself is gone for the owner afterwards. +#[tokio::test] +#[ignore] +async fn test_instructions_version_deletion_reads_are_author_only() { + let owner = Keys::generate(); + let (version, deletion) = store_deleted_version(&owner).await; + aod::assert_deletion_visibility(&owner, &owner, deletion.id, true).await; + aod::assert_deletion_visibility(&Keys::generate(), &owner, deletion.id, false).await; + + let by_id = Filter::new().id(version.id); + assert!( + aod::http_query_ids(&aod::http_client(), &owner, &by_id) + .await + .is_empty(), + "the deleted version must be gone over HTTP for its owner" + ); + let mut ws = BuzzTestClient::connect(&relay_url(), &owner) + .await + .expect("connect"); + assert!( + aod::ws_query_ids(&mut ws, &by_id).await.is_empty(), + "the deleted version must be gone over WS for its owner" + ); + assert_eq!(aod::ws_count(&mut ws, &by_id).await, 0); + ws.disconnect().await.expect("disconnect"); +} + +/// Subscribed while the version still exists, the owner receives its +/// deletion live and another reader does not. +#[tokio::test] +#[ignore] +async fn test_instructions_version_deletion_live_fanout_is_author_only() { + let client = aod::http_client(); + let owner = Keys::generate(); + let version = agent_version(&owner, &new_agent(), now()); + aod::submit_ok(&client, &owner, &version).await; + + let live = Filter::new() + .kind(Kind::EventDeletion) + .author(owner.public_key()); + let mut subscribers = Vec::new(); + for keys in [owner.clone(), Keys::generate()] { + let mut ws = BuzzTestClient::connect(&relay_url(), &keys) + .await + .expect("connect"); + let sid = sub_id("deletion-live"); + ws.subscribe(&sid, vec![live.clone()]).await.expect("REQ"); + assert!(ws + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE") + .is_empty()); + subscribers.push((ws, sid)); + } + + let deletion = aod::build_deletion( + &owner, + vec![ + aod::tag(&["e", &version.id.to_hex()]), + aod::tag(&["k", &INSTRUCTIONS_VERSION_KIND.to_string()]), + ], + ); + aod::submit_ok(&client, &owner, &deletion).await; + + for (i, (mut ws, sid)) in subscribers.into_iter().enumerate() { + let delivered = aod::live_event_ids(&mut ws, &sid) + .await + .contains(&deletion.id); + assert_eq!( + delivered, + i == 0, + "live delivery to subscriber {i} (0 = owner)" + ); + ws.disconnect().await.expect("disconnect"); + } +} + +/// A deletion of a version must carry `["k","44300"]`: one without it, or +/// with another kind, is rejected whether the version is live or already +/// deleted. +#[tokio::test] +#[ignore] +async fn test_instructions_version_deletion_requires_k_tag() { + let client = aod::http_client(); + let owner = Keys::generate(); + let version = agent_version(&owner, &new_agent(), now()); + aod::submit_ok(&client, &owner, &version).await; + let target = aod::tag(&["e", &version.id.to_hex()]); + let k = INSTRUCTIONS_VERSION_KIND.to_string(); + + for tags in [ + vec![target.clone()], + vec![target.clone(), aod::tag(&["k", "1"])], + vec![target.clone(), aod::tag(&["k", &format!("0{k}")])], + ] { + let (accepted, msg) = + aod::submit(&client, &owner, &aod::build_deletion(&owner, tags.clone())).await; + assert!(!accepted, "deletion {tags:?} must be rejected (got: {msg})"); + } + + let (deleted, _) = store_deleted_version(&owner).await; + let untagged = aod::build_deletion(&owner, vec![aod::tag(&["e", &deleted.id.to_hex()])]); + let (accepted, msg) = aod::submit(&client, &owner, &untagged).await; + assert!( + !accepted, + "an untagged deletion of a deleted version must be rejected (got: {msg})" + ); +} diff --git a/crates/buzz-test-client/tests/support/author_only_deletion.rs b/crates/buzz-test-client/tests/support/author_only_deletion.rs new file mode 100644 index 0000000000..9736352b5d --- /dev/null +++ b/crates/buzz-test-client/tests/support/author_only_deletion.rs @@ -0,0 +1,219 @@ +//! Shared helpers for NIP-09 deletion e2e tests of author-only kinds. Each +//! test binary covering a kind in `AUTHOR_ONLY_KINDS` includes this module. +#![allow(dead_code)] + +use std::time::Duration; + +use buzz_test_client::{BuzzTestClient, RelayMessage, TestClientError}; +use nostr::{EventBuilder, EventId, Filter, Keys, Kind, Tag}; +use reqwest::Client; +use serde_json::Value; + +pub fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3001".to_string()) +} + +pub fn relay_http_url() -> String { + relay_url() + .replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() +} + +pub fn sub_id(name: &str) -> String { + format!("e2e-aod-{name}-{}", uuid::Uuid::new_v4()) +} + +pub fn http_client() -> Client { + Client::builder() + .timeout(Duration::from_secs(10)) + .build() + .expect("failed to build HTTP client") +} + +pub fn tag(parts: &[&str]) -> Tag { + Tag::parse(parts.to_vec()).expect("valid tag") +} + +pub fn build_deletion(keys: &Keys, tags: Vec) -> nostr::Event { + build_deletion_with_reason(keys, tags, "") +} + +pub fn build_deletion_with_reason(keys: &Keys, tags: Vec, reason: &str) -> nostr::Event { + EventBuilder::new(Kind::EventDeletion, reason) + .tags(tags) + .sign_with_keys(keys) + .unwrap() +} + +pub async fn submit(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) { + let resp = client + .post(format!("{}/events", relay_http_url())) + .header("X-Pubkey", keys.public_key().to_hex()) + .json(event) + .send() + .await + .expect("submit event"); + let ok = resp.status().is_success(); + let body: Value = resp.json().await.expect("parse response"); + if ok { + ( + body["accepted"].as_bool().unwrap_or(false), + body["message"].as_str().unwrap_or("").to_string(), + ) + } else { + (false, body["error"].as_str().unwrap_or("").to_string()) + } +} + +pub async fn submit_ok(client: &Client, keys: &Keys, event: &nostr::Event) { + let (accepted, msg) = submit(client, keys, event).await; + assert!(accepted, "setup event rejected: {msg}"); +} + +/// Filters that each match only the deletion `store_deleted_reminder` left: +/// by kind, mixed kinds, and id. The id filter is the kindless COUNT case; +/// broader kindless filters can match `#p`-gated kinds, so the relay closes +/// them before any read runs. +pub fn deletion_filters(author: &Keys, deletion: EventId) -> Vec { + vec![ + Filter::new() + .kind(Kind::EventDeletion) + .author(author.public_key()), + Filter::new() + .kinds([Kind::EventDeletion, Kind::TextNote]) + .author(author.public_key()), + Filter::new().id(deletion), + ] +} + +pub async fn http_query_ids(client: &Client, reader: &Keys, filter: &Filter) -> Vec { + let resp = client + .post(format!("{}/query", relay_http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .json(&vec![filter]) + .send() + .await + .expect("query events"); + assert!( + resp.status().is_success(), + "query failed: {}", + resp.status() + ); + resp.json::>() + .await + .expect("parse query response") + .iter() + .filter_map(|e| e["id"].as_str().map(str::to_string)) + .collect() +} + +pub async fn http_count(client: &Client, reader: &Keys, filter: &Filter) -> u64 { + let resp = client + .post(format!("{}/count", relay_http_url())) + .header("X-Pubkey", reader.public_key().to_hex()) + .json(&vec![filter]) + .send() + .await + .expect("count events"); + assert!( + resp.status().is_success(), + "count failed: {}", + resp.status() + ); + let body: Value = resp.json().await.expect("parse count response"); + body["count"].as_u64().expect("count field") +} + +pub async fn ws_query_ids(ws: &mut BuzzTestClient, filter: &Filter) -> Vec { + let sid = sub_id("req"); + ws.subscribe(&sid, vec![filter.clone()]).await.expect("REQ"); + let events = ws + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("EOSE"); + ws.close_subscription(&sid).await.expect("CLOSE"); + events.into_iter().map(|e| e.id).collect() +} + +pub async fn ws_count(ws: &mut BuzzTestClient, filter: &Filter) -> u64 { + let sid = sub_id("count"); + ws.send_raw(&serde_json::json!(["COUNT", sid, filter])) + .await + .expect("COUNT"); + loop { + match ws + .recv_event(Duration::from_secs(5)) + .await + .expect("COUNT reply") + { + RelayMessage::Count { + subscription_id, + count, + } if subscription_id == sid => return count, + RelayMessage::Closed { + subscription_id, + message, + } if subscription_id == sid => panic!("COUNT closed: {message}"), + _ => {} + } + } +} + +/// Every historical read surface returns the deletion to `reader` iff +/// `visible`: WS REQ, WS COUNT, HTTP `/query` and HTTP `/count`, by author +/// and by id. +pub async fn assert_deletion_visibility( + reader: &Keys, + author: &Keys, + deletion: EventId, + visible: bool, +) { + let client = http_client(); + let mut ws = BuzzTestClient::connect(&relay_url(), reader) + .await + .expect("connect reader"); + let expected = u64::from(visible); + let mut mismatches = Vec::new(); + for filter in deletion_filters(author, deletion) { + let observed = [ + ("WS REQ", ws_query_ids(&mut ws, &filter).await.len() as u64), + ("WS COUNT", ws_count(&mut ws, &filter).await), + ( + "HTTP /query", + http_query_ids(&client, reader, &filter).await.len() as u64, + ), + ("HTTP /count", http_count(&client, reader, &filter).await), + ]; + for (surface, n) in observed { + if n != expected { + mismatches.push(format!("{surface} {filter:?}: {n}, expected {expected}")); + } + } + } + assert!(mismatches.is_empty(), "{}", mismatches.join("\n")); + ws.disconnect().await.expect("disconnect"); +} + +/// IDs of the events subscription `sid` receives until 2s pass with no +/// message. A dropped connection or a `CLOSED` for `sid` fails the test, so +/// a dead subscription can never pass for one that saw nothing. +pub async fn live_event_ids(ws: &mut BuzzTestClient, sid: &str) -> Vec { + let mut ids = Vec::new(); + loop { + match ws.recv_event(Duration::from_secs(2)).await { + Ok(RelayMessage::Event { + subscription_id, + event, + }) if subscription_id == sid => ids.push(event.id), + Ok(RelayMessage::Closed { + subscription_id, + message, + }) if subscription_id == sid => panic!("{sid} closed: {message}"), + Ok(_) => {} + Err(TestClientError::Timeout) => return ids, + Err(e) => panic!("{sid} receive failed: {e}"), + } + } +}