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

1592 lines
57 KiB
Rust

//! End-to-end tests for kind:30175 persona events (NIP-AP).
//!
//! These tests verify the relay correctly handles persona events:
//! - Accepts valid persona events with proper d-tag slugs
//! - Enforces NIP-33 replacement semantics (same d-tag, newer timestamp wins)
//! - Rejects invalid d-tag values (empty, too long, invalid characters)
//!
//! # Running
//!
//! Start the relay, then run:
//!
//! ```text
//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_persona -- --ignored
//! ```
use std::time::Duration;
use buzz_test_client::{BuzzTestClient, RelayMessage};
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
use reqwest::Client;
use serde_json::Value;
const PERSONA_KIND: u16 = 30175;
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
fn relay_http_url() -> String {
relay_url()
.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
fn http_client() -> Client {
Client::builder()
.timeout(Duration::from_secs(10))
.build()
.expect("failed to build HTTP client")
}
/// Submit an event via the NIP-98 HTTP bridge (`POST /events`).
async fn submit_event_http(client: &Client, keys: &Keys, event: &nostr::Event) -> (bool, String) {
let pubkey_hex = keys.public_key().to_hex();
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 event");
let status = resp.status().as_u16();
let body: Value = resp.json().await.expect("parse response");
if status == 200 {
let accepted = body["accepted"].as_bool().unwrap_or(false);
let message = body["message"].as_str().unwrap_or("").to_string();
(accepted, message)
} else {
let message = body["error"].as_str().unwrap_or("").to_string();
(false, message)
}
}
/// Query events via the NIP-98 HTTP bridge (`POST /query`).
async fn query_events_http(client: &Client, pubkey_hex: &str, filters: Vec<Filter>) -> Vec<Value> {
let resp = client
.post(format!("{}/query", relay_http_url()))
.header("X-Pubkey", pubkey_hex)
.header("Content-Type", "application/json")
.json(&filters)
.send()
.await
.expect("query events");
assert!(
resp.status().is_success(),
"query failed: {}",
resp.status()
);
resp.json::<Vec<Value>>()
.await
.expect("parse query response")
}
/// Count events via the NIP-98 HTTP bridge (`POST /count`).
async fn count_events_http(
client: &Client,
pubkey_hex: &str,
filters: Vec<Filter>,
) -> Result<u64, (u16, String)> {
let resp = client
.post(format!("{}/count", relay_http_url()))
.header("X-Pubkey", pubkey_hex)
.header("Content-Type", "application/json")
.json(&filters)
.send()
.await
.expect("count events");
let status = resp.status().as_u16();
let body: Value = resp.json().await.expect("parse count response");
if status == 200 {
Ok(body["count"].as_u64().unwrap_or(0))
} else {
let msg = body["error"].as_str().unwrap_or("").to_string();
Err((status, msg))
}
}
fn sub_id(name: &str) -> String {
format!("e2e-persona-{name}-{}", uuid::Uuid::new_v4())
}
/// Create an open-visibility channel so a kind:9 event can be read by any relay member.
///
/// Uses `visibility=open` so the foreign reader does not need an explicit membership
/// entry — the relay allows any authenticated member to read open-channel events.
async fn create_test_channel(keys: &Keys) -> String {
let channel_uuid = uuid::Uuid::new_v4();
let channel_name = format!("persona-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 (ok, msg) = submit_event_http(&http_client(), keys, &event).await;
assert!(ok, "channel creation rejected: {msg}");
channel_uuid.to_string()
}
/// Build a minimal persona event with the given d-tag and content.
fn persona_event(keys: &Keys, d_tag: &str, content: &str) -> nostr::Event {
EventBuilder::new(Kind::Custom(PERSONA_KIND), content)
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
.sign_with_keys(keys)
.unwrap()
}
/// Build a persona event with an explicit created_at timestamp.
fn persona_event_at(keys: &Keys, d_tag: &str, content: &str, created_at: u64) -> nostr::Event {
EventBuilder::new(Kind::Custom(PERSONA_KIND), content)
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap()
}
#[tokio::test]
#[ignore]
async fn test_persona_publish_and_query() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("test-persona-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let content = serde_json::json!({
"name": &d_tag,
"display_name": "Test Persona",
"description": "A test persona for E2E validation"
})
.to_string();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Publish persona event
let event = persona_event(&keys, &d_tag, &content);
let event_id = event.id;
let ok = client
.send_event(event.clone())
.await
.expect("send persona");
assert!(ok.accepted, "relay rejected persona event: {}", ok.message);
// Query the exact event this test published. Replacement-address filters
// are exercised below and may legitimately replay a racing duplicate.
let sid = sub_id("query");
let filter = Filter::new().id(event_id);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect events");
let ev = events
.iter()
.find(|candidate| candidate.id == event_id)
.expect("published persona event was not returned");
assert_eq!(ev.content, content);
assert_eq!(ev.pubkey, keys.public_key());
assert_eq!(ev.kind, Kind::Custom(PERSONA_KIND));
client.disconnect().await.expect("disconnect");
}
/// NIP-AP revision: a prompt-less definition (display_name only) must ingest
/// through the REAL relay path and round-trip byte-for-byte — not just pass
/// the envelope validator helper.
#[tokio::test]
#[ignore]
async fn test_promptless_persona_ingests_and_round_trips() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("promptless-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let content = serde_json::json!({ "display_name": "Config Only" }).to_string();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = persona_event(&keys, &d_tag, &content);
let ok = client.send_event(event).await.expect("send promptless");
assert!(
ok.accepted,
"relay rejected prompt-less persona: {}",
ok.message
);
let sid = sub_id("promptless");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1);
assert_eq!(events[0].content, content, "byte-for-byte round-trip");
client.disconnect().await.expect("disconnect");
}
/// NIP-AP revision: the reserved behavioral fields ingest through the real
/// relay path as opaque content and round-trip byte-for-byte.
#[tokio::test]
#[ignore]
async fn test_behavioral_fields_persona_ingests_and_round_trips() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("behavioral-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let content = serde_json::json!({
"display_name": "Behavioral",
"system_prompt": "p",
"respond_to": "owner-only",
"respond_to_allowlist": [],
"parallelism": 2
})
.to_string();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = persona_event(&keys, &d_tag, &content);
let ok = client.send_event(event).await.expect("send behavioral");
assert!(
ok.accepted,
"relay rejected behavioral-fields persona: {}",
ok.message
);
let sid = sub_id("behavioral");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1);
assert_eq!(events[0].content, content, "byte-for-byte round-trip");
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_nip33_replacement_newer_wins() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("replace-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Publish older version
let now = Timestamp::now().as_secs();
let old_content = r#"{"name":"old","display_name":"Old","description":"Old version"}"#;
let old_event = persona_event_at(&keys, &d_tag, old_content, now - 100);
let ok = client.send_event(old_event).await.expect("send old");
assert!(ok.accepted, "relay rejected old event: {}", ok.message);
// Publish newer version with same d-tag
let new_content = r#"{"name":"new","display_name":"New","description":"New version"}"#;
let new_event = persona_event_at(&keys, &d_tag, new_content, now);
let ok = client.send_event(new_event).await.expect("send new");
assert!(ok.accepted, "relay rejected new event: {}", ok.message);
// Query — should return only the newer event
let sid = sub_id("replace");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1, "NIP-33: only newest event should remain");
let ev = &events[0];
assert_eq!(ev.content, new_content, "should be the newer version");
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_nip33_older_does_not_replace_newer() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("no-replace-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Publish newer version first
let now = Timestamp::now().as_secs();
let new_content = r#"{"name":"new","display_name":"New","description":"Newer"}"#;
let new_event = persona_event_at(&keys, &d_tag, new_content, now);
let ok = client.send_event(new_event).await.expect("send new");
assert!(ok.accepted, "relay rejected new event: {}", ok.message);
// Publish older version — relay should accept but not replace
let old_content = r#"{"name":"old","display_name":"Old","description":"Older"}"#;
let old_event = persona_event_at(&keys, &d_tag, old_content, now - 100);
let _ok = client.send_event(old_event).await.expect("send old");
// Note: relay may accept or reject the older event depending on implementation.
// The key assertion is that querying returns the newer one.
// Query — should still return the newer event
let sid = sub_id("no-replace");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key())
.custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]);
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1, "should have exactly one event");
let ev = &events[0];
assert_eq!(ev.content, new_content, "newer event should persist");
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_empty_d_tag() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = EventBuilder::new(
Kind::Custom(PERSONA_KIND),
r#"{"name":"x","display_name":"X","description":"X"}"#,
)
.tags(vec![Tag::parse(["d", ""]).unwrap()])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.expect("send");
assert!(!ok.accepted, "relay should reject persona with empty d-tag");
assert!(
ok.message.contains("empty") || ok.message.contains("d") || ok.message.contains("tag"),
"rejection message should mention d-tag issue, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_missing_d_tag() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// No d-tag at all
let event = EventBuilder::new(
Kind::Custom(PERSONA_KIND),
r#"{"name":"x","display_name":"X","description":"X"}"#,
)
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(event).await.expect("send");
assert!(!ok.accepted, "relay should reject persona without d-tag");
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_d_tag_too_long() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// 65 characters — exceeds the 64-char limit
let long_slug = "a".repeat(65);
let event = persona_event(
&keys,
&long_slug,
r#"{"name":"x","display_name":"X","description":"X"}"#,
);
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay should reject persona with d-tag > 64 chars"
);
assert!(
ok.message.contains("long") || ok.message.contains("64"),
"rejection should mention length, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_d_tag_uppercase() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = persona_event(
&keys,
"My-Persona",
r#"{"name":"x","display_name":"X","description":"X"}"#,
);
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay should reject persona with uppercase d-tag"
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_d_tag_special_chars() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = persona_event(
&keys,
"my.persona!",
r#"{"name":"x","display_name":"X","description":"X"}"#,
);
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay should reject persona with special chars in d-tag"
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_rejects_d_tag_starting_with_underscore() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Slug must start with [a-z0-9], not underscore
let event = persona_event(
&keys,
"_invalid",
r#"{"name":"x","display_name":"X","description":"X"}"#,
);
let ok = client.send_event(event).await.expect("send");
assert!(
!ok.accepted,
"relay should reject persona with d-tag starting with underscore"
);
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_accepts_valid_slugs() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Various valid slug patterns
let valid_slugs = [
"a",
"my-persona",
"persona_v2",
"0-starts-with-digit",
"a-b-c-d-e",
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // exactly 64 chars
];
for slug in valid_slugs {
let content = format!(
r#"{{"name":"{}","display_name":"Test","description":"Valid slug test"}}"#,
slug
);
let event = persona_event(&keys, slug, &content);
let ok = client.send_event(event).await.expect("send");
assert!(
ok.accepted,
"relay should accept valid slug '{}', got rejection: {}",
slug, ok.message
);
}
client.disconnect().await.expect("disconnect");
}
#[tokio::test]
#[ignore]
async fn test_persona_multiple_per_author() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Publish two different personas (different d-tags)
let slug_a = format!("persona-a-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let slug_b = format!("persona-b-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let event_a = persona_event(
&keys,
&slug_a,
r#"{"name":"a","display_name":"Persona A","description":"First"}"#,
);
let event_b = persona_event(
&keys,
&slug_b,
r#"{"name":"b","display_name":"Persona B","description":"Second"}"#,
);
let ok_a = client.send_event(event_a).await.expect("send A");
assert!(ok_a.accepted, "persona A rejected: {}", ok_a.message);
let ok_b = client.send_event(event_b).await.expect("send B");
assert!(ok_b.accepted, "persona B rejected: {}", ok_b.message);
// Query all personas by this author
let sid = sub_id("multi");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(keys.public_key());
client
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
events.len() >= 2,
"expected at least 2 persona events, got {}",
events.len()
);
client.disconnect().await.expect("disconnect");
}
// ─────────────────────────────────────────────────────────────────────────────
// Shared-read-gate tests (author-only-unless-shared semantics for kind:30175)
// ─────────────────────────────────────────────────────────────────────────────
/// Build a persona event with an optional `["shared","true"]` tag.
fn persona_event_with_shared(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event {
let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()];
if shared {
tags.push(Tag::parse(["shared", "true"]).unwrap());
}
EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"test"}"#)
.tags(tags)
.sign_with_keys(keys)
.unwrap()
}
/// Build a persona event with an optional `["shared","true"]` tag and an
/// explicit `created_at` — needed for NIP-33 replacement tests where two
/// events could otherwise land in the same second and be ordered by event-id
/// tie-break rather than timestamp.
fn persona_event_with_shared_at(
keys: &Keys,
d_tag: &str,
shared: bool,
created_at: u64,
) -> nostr::Event {
let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()];
if shared {
tags.push(Tag::parse(["shared", "true"]).unwrap());
}
EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"test"}"#)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap()
}
/// AC-1: Foreign reader receives ONLY shared heads; author receives all own heads.
///
/// Gate changed: `test_persona_publish_and_query` queries by id (author, always
/// allowed), so it is unaffected. The "all personas by author" variant in
/// `test_persona_multiple_per_author` uses `authors:[self]`, also unaffected.
/// This test is the cross-author assertion.
#[tokio::test]
#[ignore]
async fn test_persona_shared_read_gate_foreign_sees_only_shared() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let d_unshared = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let d_shared = format!("pub-{}", &uuid::Uuid::new_v4().to_string()[..8]);
// Author publishes one unshared and one shared persona.
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ev_unshared = persona_event_with_shared(&author_keys, &d_unshared, false);
let ev_shared = persona_event_with_shared(&author_keys, &d_shared, true);
let shared_id = ev_shared.id;
let ok = author.send_event(ev_unshared).await.expect("send unshared");
assert!(ok.accepted, "unshared ingest rejected: {}", ok.message);
let ok = author.send_event(ev_shared).await.expect("send shared");
assert!(ok.accepted, "shared ingest rejected: {}", ok.message);
// Foreign reader queries all personas by the author.
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("fg-all");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
foreign
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
// Foreign should see ONLY the shared event.
assert!(
!events.iter().any(|e| e.tags.iter().any(|t| t
.as_slice()
.get(1)
.is_some_and(|v| v.as_str() == d_unshared))),
"foreign should NOT see unshared persona"
);
assert!(
events.iter().any(|e| e.id == shared_id),
"foreign should see the shared persona"
);
// Author queries all own personas — must see both.
let sid_author = sub_id("auth-all");
let filter_self = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
author
.subscribe(&sid_author, vec![filter_self])
.await
.expect("subscribe author");
let author_events = author
.collect_until_eose(&sid_author, Duration::from_secs(5))
.await
.expect("collect author");
assert!(
author_events.len() >= 2,
"author should see both own personas, got {}",
author_events.len()
);
author.disconnect().await.expect("disconnect author");
foreign.disconnect().await.expect("disconnect foreign");
}
/// AC-2: `{ids:[unshared-foreign-30175-id]}` returns nothing to a foreign reader.
#[tokio::test]
#[ignore]
async fn test_persona_ids_lookup_unshared_returns_nothing_to_foreign() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let d_tag = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let ev = persona_event_with_shared(&author_keys, &d_tag, false);
let event_id = ev.id;
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ok = author.send_event(ev).await.expect("send");
assert!(ok.accepted, "ingest rejected: {}", ok.message);
author.disconnect().await.expect("disconnect");
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("ids-unshared");
let filter = Filter::new().id(event_id);
foreign
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
events.is_empty(),
"ids-lookup of unshared persona must return nothing to foreign reader, got {:?}",
events.iter().map(|e| e.id).collect::<Vec<_>>()
);
foreign.disconnect().await.expect("disconnect");
}
/// AC-3: COUNT over 30175 uses the fallback path; foreign unshared events are excluded.
///
/// The relay does not pre-block COUNT on persona kinds (unlike AUTHOR_ONLY_KINDS).
/// We verify the per-event fallback fires correctly by cross-checking: the foreign
/// reader's REQ count equals the shared-persona count, not total-persona count.
#[tokio::test]
#[ignore]
async fn test_persona_count_excludes_foreign_unshared() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let d_unshared = format!("priv-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let d_shared = format!("pub-{}", &uuid::Uuid::new_v4().to_string()[..8]);
// Publish one unshared and one shared persona.
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ok = author
.send_event(persona_event_with_shared(&author_keys, &d_unshared, false))
.await
.expect("send unshared");
assert!(ok.accepted, "unshared rejected: {}", ok.message);
let ok = author
.send_event(persona_event_with_shared(&author_keys, &d_shared, true))
.await
.expect("send shared");
assert!(ok.accepted, "shared rejected: {}", ok.message);
author.disconnect().await.expect("disconnect author");
// Foreign sends COUNT for {kinds:[30175], authors:[author]} — uses fallback path
// and must exclude the unshared event.
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("count-persona");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
let count_msg = serde_json::json!(["COUNT", sid, filter]);
foreign.send_raw(&count_msg).await.expect("send COUNT");
// The relay returns ["COUNT", sub_id, {"count": N}].
// buzz-ws-client parses this into RelayMessage::Count.
let result = foreign.recv_event(Duration::from_secs(5)).await;
let count: u64 = match result {
Ok(RelayMessage::Count { count, .. }) => count,
Ok(RelayMessage::Closed { message, .. }) => {
panic!("COUNT closed unexpectedly: {message}");
}
Ok(other) => panic!("unexpected relay message for COUNT: {other:?}"),
Err(e) => panic!("unexpected error for COUNT: {e}"),
};
assert_eq!(
count, 1,
"foreign COUNT should see only the 1 shared persona, got {count}"
);
foreign.disconnect().await.expect("disconnect foreign");
}
/// AC-4a: Unshared persona publish is NOT delivered to foreign live subscription.
/// AC-4b: Shared persona publish IS delivered to foreign live subscription.
/// AC-4c: NIP-33 replace shared→unshared makes subsequent foreign REQs return nothing,
/// and the live subscription for the foreign reader receives no event.
///
/// Uses explicit monotonic `created_at` timestamps so NIP-33 head ordering is
/// deterministic — same-second events would otherwise be ordered by event-id
/// tie-break, making it possible for the "wrong" head to win.
#[tokio::test]
#[ignore]
async fn test_persona_live_fanout_shared_gate() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let d_tag = format!("gate-{}", &uuid::Uuid::new_v4().to_string()[..8]);
// Use strictly increasing timestamps relative to now: now-2 < now-1 < now.
// This satisfies the relay's timestamp-skew check while keeping NIP-33
// ordering deterministic (t0 < t1 < t2 so same d-tag replacements always
// promote the highest timestamp regardless of event-id ordering).
let now = nostr::Timestamp::now().as_secs();
let t0: u64 = now.saturating_sub(2);
let t1: u64 = now.saturating_sub(1);
let t2: u64 = now;
// Foreign subscribes to the test author's kind:30175 events BEFORE the author
// publishes. Scoped to this author so concurrent persona tests publishing their
// own 30175s don't trip the leak-panic (parallel suite interference).
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("fanout-gate");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
foreign
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
// Drain historical EOSE.
let _ = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("drain eose");
// Author publishes an UNSHARED persona (t0) — foreign must NOT receive it.
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ev_unshared = persona_event_with_shared_at(&author_keys, &d_tag, false, t0);
let unshared_id = ev_unshared.id;
let ok = author.send_event(ev_unshared).await.expect("send unshared");
assert!(ok.accepted, "unshared rejected: {}", ok.message);
tokio::time::sleep(Duration::from_millis(300)).await;
// No event should arrive for foreign.
let result = foreign.recv_event(Duration::from_millis(500)).await;
match result {
Err(buzz_test_client::TestClientError::Timeout) => {}
Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(PERSONA_KIND) => {
panic!(
"foreign MUST NOT receive unshared persona via live fan-out \
(got event id={} author={})",
event.id, event.pubkey
)
}
_ => {}
}
// Verify the unshared event IS the NIP-33 head for the author (self-query).
let sid_head_check = sub_id("head-unshared");
let filter_self = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
author
.subscribe(&sid_head_check, vec![filter_self])
.await
.expect("subscribe head-check");
let author_events = author
.collect_until_eose(&sid_head_check, Duration::from_secs(5))
.await
.expect("collect head-check");
assert!(
author_events.iter().any(|e| e.id == unshared_id),
"unshared event must be the NIP-33 head visible to author"
);
// Author republishes with ["shared","true"] at t1 — t1 > t0 so it wins.
// Foreign MUST receive it via live fan-out.
let ev_shared = persona_event_with_shared_at(&author_keys, &d_tag, true, t1);
let shared_id = ev_shared.id;
let ok = author.send_event(ev_shared).await.expect("send shared");
assert!(ok.accepted, "shared rejected: {}", ok.message);
tokio::time::sleep(Duration::from_millis(300)).await;
let msg = foreign
.recv_event(Duration::from_secs(3))
.await
.expect("recv shared event");
match msg {
RelayMessage::Event { event, .. } => {
assert_eq!(event.id, shared_id, "received wrong event via fanout");
}
other => panic!("expected shared persona event via fanout, got: {other:?}"),
}
// Verify shared event is now the NIP-33 head (self-query sees shared_id).
let sid_head_shared = sub_id("head-shared");
let filter_self2 = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
author
.subscribe(&sid_head_shared, vec![filter_self2])
.await
.expect("subscribe head-shared");
let author_events2 = author
.collect_until_eose(&sid_head_shared, Duration::from_secs(5))
.await
.expect("collect head-shared");
assert!(
author_events2.iter().any(|e| e.id == shared_id),
"shared event must be the NIP-33 head visible to author"
);
// AC-4c: Author republishes WITHOUT shared tag at t2 — NIP-33 replaces the head.
// The foreign live subscription must NOT receive any event for this publish
// (unshared fan-out is blocked), and a subsequent foreign REQ must return nothing.
let ev_unshared2 = persona_event_with_shared_at(&author_keys, &d_tag, false, t2);
let unshared2_id = ev_unshared2.id;
let ok = author
.send_event(ev_unshared2)
.await
.expect("send unshared2");
assert!(ok.accepted, "unshared2 rejected: {}", ok.message);
tokio::time::sleep(Duration::from_millis(300)).await;
// Foreign live subscription must NOT receive the unshared replacement.
let live_result = foreign.recv_event(Duration::from_millis(500)).await;
match live_result {
Err(buzz_test_client::TestClientError::Timeout) => {}
Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(PERSONA_KIND) => {
panic!(
"foreign MUST NOT receive unshared replacement via live fan-out \
(got event id={} author={})",
event.id, event.pubkey
)
}
_ => {}
}
// Verify unshared2 is now the NIP-33 head for the author.
let sid_head_unshared2 = sub_id("head-unshared2");
let filter_self3 = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
author
.subscribe(&sid_head_unshared2, vec![filter_self3])
.await
.expect("subscribe head-unshared2");
let author_events3 = author
.collect_until_eose(&sid_head_unshared2, Duration::from_secs(5))
.await
.expect("collect head-unshared2");
assert!(
author_events3.iter().any(|e| e.id == unshared2_id),
"unshared2 event must be the NIP-33 head visible to author"
);
// Foreign REQ post-unshare must see nothing for this author's personas.
let sid2 = sub_id("fanout-gate-post-unshare");
let filter2 = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
foreign
.subscribe(&sid2, vec![filter2])
.await
.expect("subscribe2");
let events = foreign
.collect_until_eose(&sid2, Duration::from_secs(5))
.await
.expect("collect post-unshare");
assert!(
events.is_empty(),
"after removing shared tag, foreign REQ must return nothing, got {} events",
events.len()
);
author.disconnect().await.expect("disconnect author");
foreign.disconnect().await.expect("disconnect foreign");
}
/// AC-5: Ingest rejects ["shared","false"], ["shared","x"], and duplicate shared tags.
/// Accepts ["shared","true"] and tag-absent.
#[tokio::test]
#[ignore]
async fn test_persona_ingest_shared_tag_validation() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Accept: no shared tag
let ev = persona_event_with_shared(
&keys,
&format!("no-shared-{}", &uuid::Uuid::new_v4().to_string()[..8]),
false,
);
let ok = client.send_event(ev).await.expect("send no-shared");
assert!(
ok.accepted,
"no-shared-tag persona must be accepted: {}",
ok.message
);
// Accept: ["shared","true"]
let ev = persona_event_with_shared(
&keys,
&format!("shared-true-{}", &uuid::Uuid::new_v4().to_string()[..8]),
true,
);
let ok = client.send_event(ev).await.expect("send shared-true");
assert!(
ok.accepted,
"shared=true persona must be accepted: {}",
ok.message
);
// Reject: ["shared","false"]
let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#)
.tags(vec![
Tag::parse([
"d",
&format!("shared-false-{}", &uuid::Uuid::new_v4().to_string()[..8]),
])
.unwrap(),
Tag::parse(["shared", "false"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(ev).await.expect("send shared-false");
assert!(!ok.accepted, "shared=false persona must be rejected");
assert!(
ok.message.contains("shared") || ok.message.contains("invalid"),
"unexpected rejection message: {}",
ok.message
);
// Reject: ["shared","x"] — any value other than "true" is malformed.
let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#)
.tags(vec![
Tag::parse([
"d",
&format!("shared-x-{}", &uuid::Uuid::new_v4().to_string()[..8]),
])
.unwrap(),
Tag::parse(["shared", "x"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(ev).await.expect("send shared-x");
assert!(
!ok.accepted,
"shared=x persona must be rejected: {}",
ok.message
);
// Reject: ["shared"] — value is missing (tag has only 1 element after the key).
let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#)
.tags(vec![
Tag::parse([
"d",
&format!("shared-no-value-{}", &uuid::Uuid::new_v4().to_string()[..8]),
])
.unwrap(),
Tag::parse(["shared"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(ev).await.expect("send shared-no-value");
assert!(
!ok.accepted,
"shared tag with missing value must be rejected: {}",
ok.message
);
// Reject: duplicate shared tags
let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#)
.tags(vec![
Tag::parse([
"d",
&format!("dup-shared-{}", &uuid::Uuid::new_v4().to_string()[..8]),
])
.unwrap(),
Tag::parse(["shared", "true"]).unwrap(),
Tag::parse(["shared", "true"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = client.send_event(ev).await.expect("send dup-shared");
assert!(!ok.accepted, "duplicate shared tags must be rejected");
client.disconnect().await.expect("disconnect");
}
/// AC-6: Mixed-kind filter {kinds:[30175, 9]} does not leak foreign unshared personas,
/// but DOES pass through kind:9 events from the same author. This pins the
/// correctness property in both directions — an implementation that silently
/// drops the whole mixed-kind filter would satisfy only the absence assertion.
#[tokio::test]
#[ignore]
async fn test_persona_mixed_kind_filter_does_not_leak() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let d_tag = format!("mixed-{}", &uuid::Uuid::new_v4().to_string()[..8]);
// Create an open-visibility channel so the kind:9 control event is accessible
// to the foreign reader without an explicit membership entry.
let channel_id = create_test_channel(&author_keys).await;
// Author publishes an unshared persona AND a kind:9 message in the open channel.
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ev = persona_event_with_shared(&author_keys, &d_tag, false);
let unshared_id = ev.id;
let ok = author.send_event(ev).await.expect("send unshared");
assert!(ok.accepted, "unshared persona rejected: {}", ok.message);
// Publish a kind:9 event in the open channel so the mixed-kind filter has
// something to return and we can assert the persona gate is per-event, not
// a wholesale filter drop.
let ev9 = EventBuilder::new(Kind::Custom(9), "hello from author")
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
.sign_with_keys(&author_keys)
.unwrap();
let msg9_id = ev9.id;
let ok9 = author.send_event(ev9).await.expect("send kind:9");
assert!(ok9.accepted, "kind:9 rejected: {}", ok9.message);
author.disconnect().await.expect("disconnect author");
// Foreign queries with mixed-kind filter.
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("mixed-kind");
let filter = Filter::new()
.kinds(vec![Kind::Custom(PERSONA_KIND), Kind::Custom(9)])
.author(author_keys.public_key());
foreign
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
// Foreign must NOT see the unshared persona.
assert!(
!events.iter().any(|e| e.id == unshared_id),
"mixed-kind filter must NOT leak foreign unshared persona (id {})",
unshared_id
);
// Foreign MUST see the kind:9 event — filter is not wholesale dropped.
assert!(
events.iter().any(|e| e.id == msg9_id),
"mixed-kind filter must pass through kind:9 events (id {})",
msg9_id
);
foreign.disconnect().await.expect("disconnect foreign");
}
// ─── NIP-98 HTTP bridge persona gate tests ───────────────────────────────────
//
// These tests verify that `POST /query` and `POST /count` enforce the same
// author-only-unless-shared gate as the WebSocket paths. A foreign
// authenticated caller must not receive or count unshared kind:30175 events
// belonging to another author, even via the HTTP bridge.
/// NIP-98 bridge AC-query: `/query` cross-author gate.
///
/// - A foreign pubkey posting `{kinds:[30175],authors:[victim]}` receives only
/// the shared head, not the unshared one.
/// - A kindless `{ids:[unshared-id]}` returns nothing.
/// - A `{ids:[shared-id]}` returns the shared event.
#[tokio::test]
#[ignore]
async fn test_persona_http_query_cross_author_gate() {
let client = http_client();
let author_keys = Keys::generate();
let foreign_pubkey_hex = Keys::generate().public_key().to_hex();
let author_pubkey_hex = author_keys.public_key().to_hex();
let d_unshared = format!("priv-http-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let d_shared = format!("pub-http-{}", &uuid::Uuid::new_v4().to_string()[..8]);
// Publish via HTTP bridge (ingest path is the same).
let ev_unshared = persona_event_with_shared(&author_keys, &d_unshared, false);
let unshared_id_hex = ev_unshared.id.to_hex();
let ev_shared = persona_event_with_shared(&author_keys, &d_shared, true);
let shared_id_hex = ev_shared.id.to_hex();
let (ok, msg) = submit_event_http(&client, &author_keys, &ev_unshared).await;
assert!(ok, "unshared ingest rejected: {msg}");
let (ok, msg) = submit_event_http(&client, &author_keys, &ev_shared).await;
assert!(ok, "shared ingest rejected: {msg}");
// Foreign queries all author's kind:30175 — only shared must come back.
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
let results = query_events_http(&client, &foreign_pubkey_hex, vec![filter]).await;
let has_unshared = results.iter().any(|e| {
e.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id == unshared_id_hex)
});
let has_shared = results.iter().any(|e| {
e.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id == shared_id_hex)
});
assert!(
!has_unshared,
"/query (authors:[victim]) must NOT return unshared persona to foreign"
);
assert!(
has_shared,
"/query (authors:[victim]) must return shared persona to foreign"
);
// Author self-query must see both.
let filter_self = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
let self_results = query_events_http(&client, &author_pubkey_hex, vec![filter_self]).await;
assert!(
self_results.iter().any(|e| e
.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id == unshared_id_hex)),
"author self-query must see own unshared persona"
);
assert!(
self_results.iter().any(|e| e
.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id == shared_id_hex)),
"author self-query must see own shared persona"
);
// Kindless ids lookup for unshared event: foreign must get nothing.
let unshared_event_id = nostr::EventId::from_hex(&unshared_id_hex).unwrap();
let filter_ids = Filter::new().id(unshared_event_id);
let id_results = query_events_http(&client, &foreign_pubkey_hex, vec![filter_ids]).await;
assert!(
id_results.is_empty(),
"/query {{ids:[unshared-id]}} must return nothing to foreign, got {}",
id_results.len()
);
// Kindless ids lookup for shared event: foreign must get it.
let shared_event_id = nostr::EventId::from_hex(&shared_id_hex).unwrap();
let filter_shared_ids = Filter::new().id(shared_event_id);
let shared_id_results =
query_events_http(&client, &foreign_pubkey_hex, vec![filter_shared_ids]).await;
assert!(
shared_id_results.iter().any(|e| e
.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id == shared_id_hex)),
"/query {{ids:[shared-id]}} must return shared event to foreign"
);
}
/// NIP-98 bridge AC-count: `/count` cross-author gate.
///
/// A foreign authenticated caller counting `{kinds:[30175],authors:[victim]}`
/// must count only shared heads — not unshared ones — on both the fast SQL
/// path (prevented by `needs_shared_gate_filtering`) and the fallback path.
#[tokio::test]
#[ignore]
async fn test_persona_http_count_cross_author_gate() {
let client = http_client();
let author_keys = Keys::generate();
let foreign_pubkey_hex = Keys::generate().public_key().to_hex();
let author_pubkey_hex = author_keys.public_key().to_hex();
// Publish two unshared + one shared persona for the author.
let d1 = format!("priv1-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let d2 = format!("priv2-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let d_shared = format!("pub-cnt-{}", &uuid::Uuid::new_v4().to_string()[..8]);
for (d, shared) in [(&d1, false), (&d2, false), (&d_shared, true)] {
let ev = persona_event_with_shared(&author_keys, d, shared);
let (ok, msg) = submit_event_http(&client, &author_keys, &ev).await;
assert!(ok, "ingest rejected for {d}: {msg}");
}
// Foreign counts author's personas — must see only 1 (the shared one).
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
let foreign_count = count_events_http(&client, &foreign_pubkey_hex, vec![filter])
.await
.expect("count should succeed");
assert_eq!(
foreign_count, 1,
"foreign /count should return 1 (only shared persona), got {foreign_count}"
);
// Author self-count must see all 3.
let filter_self = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
let author_count = count_events_http(&client, &author_pubkey_hex, vec![filter_self])
.await
.expect("author count should succeed");
assert_eq!(
author_count, 3,
"author /count should return 3, got {author_count}"
);
// Wildcard count (no authors filter) for the foreign reader — must not
// include foreign unshared personas in the total.
let filter_wildcard = Filter::new().kind(Kind::Custom(PERSONA_KIND));
let wildcard_count = count_events_http(&client, &foreign_pubkey_hex, vec![filter_wildcard])
.await
.expect("wildcard count should succeed");
// We can't assert the exact number (other tests may have published shared
// personas), but we can assert it's ≥ 1 (the shared one) and verify the
// unshared ones aren't counted by checking author-scoped again.
assert!(
wildcard_count >= 1,
"wildcard count must include the shared persona"
);
// The foreign author-scoped count must still be exactly 1.
let filter_scoped = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key());
let scoped_count2 = count_events_http(&client, &foreign_pubkey_hex, vec![filter_scoped])
.await
.expect("scoped count2 should succeed");
assert_eq!(
scoped_count2, 1,
"foreign author-scoped /count must still return 1 after wildcard query, got {scoped_count2}"
);
}
/// Task-1 regression: WS REQ visibility-before-LIMIT gate.
///
/// Publishes N unshared (newer) personas followed by 1 shared (older) persona,
/// then queries with `limit < N`. Without the SQL-level visibility clause, the
/// page is filled with unshared events and the shared one never appears. With
/// the clause, unshared events are excluded before ORDER/LIMIT so the shared
/// event is returned.
///
/// Verifies at `312014d5e`: this test fails there because `query_events` did
/// not have the `shared_gated_reader` SQL clause and the private rows starved the
/// shared one off the page.
#[tokio::test]
#[ignore]
async fn test_persona_ws_req_shared_visible_with_newer_private_ahead() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
// Publish N=3 private personas with the highest timestamps, then 1 shared
// with the lowest timestamp. limit=2 means the first page has only 2
// candidates in the old (no-SQL-clause) world — both private — and the
// shared one is invisible.
let now = nostr::Timestamp::now().as_secs();
let shared_ts = now.saturating_sub(10);
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
// Publish 3 private personas at t=now, now-1, now-2 (all newer than shared).
for i in 0..3u64 {
let d = format!(
"priv-limit-{}-{}",
i,
&uuid::Uuid::new_v4().to_string()[..8]
);
let ev = persona_event_with_shared_at(&author_keys, &d, false, now.saturating_sub(i));
let ok = author.send_event(ev).await.expect("send private");
assert!(ok.accepted, "private persona {i} rejected: {}", ok.message);
}
// Publish 1 shared persona at a lower timestamp (older than all private ones).
let shared_d = format!("shared-limit-{}", &uuid::Uuid::new_v4().to_string()[..8]);
let ev_shared = persona_event_with_shared_at(&author_keys, &shared_d, true, shared_ts);
let shared_id = ev_shared.id;
let ok = author.send_event(ev_shared).await.expect("send shared");
assert!(ok.accepted, "shared persona rejected: {}", ok.message);
author.disconnect().await.expect("disconnect author");
// Foreign queries with limit=2: private rows are excluded at SQL level,
// so the shared event must appear despite having a lower timestamp.
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("limit-gate");
let filter = Filter::new()
.kind(Kind::Custom(PERSONA_KIND))
.author(author_keys.public_key())
.limit(2);
foreign
.subscribe(&sid, vec![filter])
.await
.expect("subscribe");
let events = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
events.iter().any(|e| e.id == shared_id),
"shared persona must appear even when newer private personas fill the LIMIT (got {} events)",
events.len()
);
assert!(
events.iter().all(|e| {
e.tags
.iter()
.any(|t| t.as_slice().first().is_some_and(|v| v.as_str() == "shared"))
}),
"foreign must NOT see any unshared persona"
);
foreign.disconnect().await.expect("disconnect foreign");
}
/// Task-1 regression: HTTP `/query` visibility-before-LIMIT gate.
///
/// Same scenario as the WS variant above, over the NIP-98 HTTP bridge.
#[tokio::test]
#[ignore]
async fn test_persona_http_query_shared_visible_with_newer_private_ahead() {
let client = http_client();
let author_keys = Keys::generate();
let foreign_pubkey_hex = Keys::generate().public_key().to_hex();
let now = nostr::Timestamp::now().as_secs();
let shared_ts = now.saturating_sub(10);
// Publish 3 private (newer) + 1 shared (older).
for i in 0..3u64 {
let d = format!(
"http-priv-limit-{}-{}",
i,
&uuid::Uuid::new_v4().to_string()[..8]
);
let ev = persona_event_with_shared_at(&author_keys, &d, false, now.saturating_sub(i));
let (ok, msg) = submit_event_http(&client, &author_keys, &ev).await;
assert!(ok, "private persona {i} rejected: {msg}");
}
let shared_d = format!(
"http-shared-limit-{}",
&uuid::Uuid::new_v4().to_string()[..8]
);
let ev_shared = persona_event_with_shared_at(&author_keys, &shared_d, true, shared_ts);
let shared_id_hex = ev_shared.id.to_hex();
let (ok, msg) = submit_event_http(&client, &author_keys, &ev_shared).await;
assert!(ok, "shared persona rejected: {msg}");
// Foreign /query with limit=2: shared event must appear.
let filter = serde_json::json!({
"kinds": [PERSONA_KIND],
"authors": [author_keys.public_key().to_hex()],
"limit": 2
});
let resp = client
.post(format!("{}/query", relay_http_url()))
.header("X-Pubkey", &foreign_pubkey_hex)
.header("Content-Type", "application/json")
.json(&vec![filter])
.send()
.await
.expect("query");
assert!(
resp.status().is_success(),
"query failed: {}",
resp.status()
);
let results: Vec<serde_json::Value> = resp.json().await.expect("parse");
let has_shared = results.iter().any(|e| {
e.get("id")
.and_then(|v| v.as_str())
.is_some_and(|id| id == shared_id_hex)
});
assert!(
has_shared,
"shared persona must appear in /query result even with newer private ones ahead (got {} events)",
results.len()
);
}
/// Task-2 wire-level: ingest must reject ["shared","true","extra"] over the wire.
///
/// The unit tests verify the validator directly; this test confirms the rejection
/// propagates end-to-end through the WebSocket ingest path.
#[tokio::test]
#[ignore]
async fn test_persona_ingest_rejects_three_element_shared_tag() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
// Build a persona event with a three-element ["shared","true","extra"] tag.
// nostr::Tag::parse accepts variable-length slices, so this is straightforward.
let ev = EventBuilder::new(Kind::Custom(PERSONA_KIND), r#"{"display_name":"x"}"#)
.tags(vec![
Tag::parse([
"d",
&format!("extra-{}", &uuid::Uuid::new_v4().to_string()[..8]),
])
.unwrap(),
Tag::parse(["shared", "true", "extra"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let ok = client
.send_event(ev)
.await
.expect("send three-element shared tag");
assert!(
!ok.accepted,
"three-element [\"shared\",\"true\",\"extra\"] tag must be rejected at ingest: {}",
ok.message
);
assert!(
ok.message.contains("[\"shared\",\"true\"]") || ok.message.contains("shared"),
"rejection message should reference the shared tag constraint, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}