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 <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Duncan
2026-10-13 14:54:48 -04:00
committed by Hayt
parent 52ebb559a5
commit 8be8f99b31
3 changed files with 360 additions and 212 deletions

View File

@@ -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<Tag>) -> nostr::Event {
build_deletion_with_reason(keys, tags, "")
}
fn build_deletion_with_reason(keys: &Keys, tags: Vec<Tag>, 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<Filter> {
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<String> {
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::<Vec<Value>>()
.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<EventId> {
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<EventId> {
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.

View File

@@ -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})"
);
}

View File

@@ -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<Tag>) -> nostr::Event {
build_deletion_with_reason(keys, tags, "")
}
pub fn build_deletion_with_reason(keys: &Keys, tags: Vec<Tag>, 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<Filter> {
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<String> {
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::<Vec<Value>>()
.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<EventId> {
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<EventId> {
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}"),
}
}
}