Files
buzz/crates/buzz-test-client/tests/e2e_team_catalog.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

485 lines
17 KiB
Rust

//! End-to-end tests for kind:30178 team-catalog events (NIP-AP).
//!
//! Kind 30178 is the shareable projection of a team. It joins kind:30175 in
//! `SHARED_GATED_KINDS`, so these tests assert the wire behaviour of that gate
//! at every read chokepoint (REQ, `ids` lookup, COUNT, live fan-out) plus the
//! ingest envelope rules that make the gate sound:
//! - Exactly one non-empty, bounded `d` tag — the team's stable local id, which
//! may contain a colon (`builtin-team:welcome`) unlike a persona slug.
//! - `shared`, if present, is exactly `["shared", "true"]`.
//!
//! # Running
//!
//! Start the relay, then run:
//!
//! ```text
//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_team_catalog -- --ignored
//! ```
use std::time::Duration;
use buzz_test_client::{BuzzTestClient, RelayMessage};
use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp};
const TEAM_CATALOG_KIND: u16 = 30178;
fn relay_url() -> String {
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
}
fn sub_id(name: &str) -> String {
format!("e2e-team-catalog-{name}-{}", uuid::Uuid::new_v4())
}
fn catalog_content(name: &str) -> String {
serde_json::json!({ "v": 1, "name": name, "members": [] }).to_string()
}
/// Build a kind:30178 event, optionally carrying the `["shared","true"]` opt-in.
fn catalog_event(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event {
catalog_event_at(keys, d_tag, shared, Timestamp::now().as_secs())
}
/// Same as [`catalog_event`] with an explicit `created_at`, so NIP-33 head
/// ordering is deterministic instead of resolved by event-id tie-break.
fn catalog_event_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(TEAM_CATALOG_KIND),
catalog_content("Test Team"),
)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(keys)
.unwrap()
}
fn author_filter(author: &Keys) -> Filter {
Filter::new()
.kind(Kind::Custom(TEAM_CATALOG_KIND))
.author(author.public_key())
}
fn coordinate_filter(author: &Keys, d_tag: &str) -> Filter {
author_filter(author).custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag])
}
fn d_tag_of(event: &nostr::Event) -> Option<&str> {
event.tags.iter().find_map(|t| {
let parts = t.as_slice();
if parts.first().map(|p| p.as_str()) != Some("d") {
return None;
}
Some(parts.get(1)?.as_str())
})
}
/// The author's own unshared projection round-trips at its NIP-33 coordinate.
///
/// The `d` tag is a UUID, matching the desktop team id — proof the envelope does
/// NOT apply the persona slug grammar.
#[tokio::test]
#[ignore]
async fn test_team_catalog_publish_and_query_own_unshared() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let event = catalog_event(&keys, &d_tag, false);
let event_id = event.id;
let ok = client.send_event(event).await.expect("send catalog");
assert!(ok.accepted, "relay rejected catalog event: {}", ok.message);
let sid = sub_id("own-unshared");
client
.subscribe(&sid, vec![coordinate_filter(&keys, &d_tag)])
.await
.expect("subscribe");
let events = client
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert_eq!(events.len(), 1, "author must see own unshared projection");
assert_eq!(events[0].id, event_id);
client.disconnect().await.expect("disconnect");
}
/// A built-in team id (`builtin-team:welcome`) is accepted as the `d` tag.
///
/// The colon is illegal in a persona slug; rewriting the id to fit would break
/// NIP-33 addressing against the team's own kind:30176 head.
#[tokio::test]
#[ignore]
async fn test_team_catalog_accepts_builtin_colon_d_tag() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = format!("builtin-team:{}", &uuid::Uuid::new_v4().to_string()[..8]);
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = client
.send_event(catalog_event(&keys, &d_tag, true))
.await
.expect("send catalog");
assert!(
ok.accepted,
"relay rejected colon-bearing team id: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// Ingest refuses an empty `d` tag: generic NIP-33 storage maps it to the empty
/// coordinate, collapsing every team into one `(pubkey, 30178, "")` slot.
#[tokio::test]
#[ignore]
async fn test_team_catalog_rejects_empty_d_tag() {
let url = relay_url();
let keys = Keys::generate();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = client
.send_event(catalog_event(&keys, "", false))
.await
.expect("send catalog");
assert!(!ok.accepted, "empty d-tag must be rejected");
assert!(
ok.message.contains("invalid:"),
"expected an `invalid:` refusal, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// Ingest refuses a valueless `["d"]` tag alongside a valued one. Counting only
/// tags that carry a value would see exactly one `d` here and accept the event;
/// a NIP-33 consumer that reads `["d"]` as an empty-valued first `d` tag would
/// then address the event at `""` where this relay addresses it at the team id.
#[tokio::test]
#[ignore]
async fn test_team_catalog_rejects_valueless_plus_valued_d_tags() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(
Kind::Custom(TEAM_CATALOG_KIND),
catalog_content("Two d tags"),
)
.tags(vec![
Tag::parse(["d"]).unwrap(),
Tag::parse(["d", d_tag.as_str()]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = client.send_event(event).await.expect("send catalog");
assert!(
!ok.accepted,
"a valueless `d` tag must count toward the exactly-one rule"
);
assert!(
ok.message.contains("invalid:"),
"expected an `invalid:` refusal, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// Ingest refuses a malformed `shared` tag. A three-element tag would satisfy
/// the SQL containment clause `tags @> '[["shared","true"]]'` as a superset
/// while the in-process gate reads it as unshared — the two layers must agree,
/// so such an event can never be stored.
#[tokio::test]
#[ignore]
async fn test_team_catalog_rejects_three_element_shared_tag() {
let url = relay_url();
let keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(
Kind::Custom(TEAM_CATALOG_KIND),
catalog_content("Malformed"),
)
.tags(vec![
Tag::parse(["d", d_tag.as_str()]).unwrap(),
Tag::parse(["shared", "true", "extra"]).unwrap(),
])
.sign_with_keys(&keys)
.unwrap();
let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect");
let ok = client.send_event(event).await.expect("send catalog");
assert!(!ok.accepted, "three-element shared tag must be rejected");
assert!(
ok.message.contains("invalid:"),
"expected an `invalid:` refusal, got: {}",
ok.message
);
client.disconnect().await.expect("disconnect");
}
/// REQ historical delivery: a foreign reader receives only shared projections,
/// while the author receives both of their own.
#[tokio::test]
#[ignore]
async fn test_team_catalog_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());
let d_shared = format!("pub-{}", uuid::Uuid::new_v4());
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let shared_event = catalog_event(&author_keys, &d_shared, true);
let shared_id = shared_event.id;
let ok = author
.send_event(catalog_event(&author_keys, &d_unshared, false))
.await
.expect("send unshared");
assert!(ok.accepted, "unshared ingest rejected: {}", ok.message);
let ok = author.send_event(shared_event).await.expect("send shared");
assert!(ok.accepted, "shared ingest rejected: {}", ok.message);
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("fg-all");
foreign
.subscribe(&sid, vec![author_filter(&author_keys)])
.await
.expect("subscribe");
let events = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
!events
.iter()
.any(|e| d_tag_of(e) == Some(d_unshared.as_str())),
"foreign reader must NOT see the unshared projection"
);
assert!(
events.iter().any(|e| e.id == shared_id),
"foreign reader must see the shared projection"
);
let sid_author = sub_id("auth-all");
author
.subscribe(&sid_author, vec![author_filter(&author_keys)])
.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 must see both own projections, got {}",
author_events.len()
);
author.disconnect().await.expect("disconnect author");
foreign.disconnect().await.expect("disconnect foreign");
}
/// Knowing an event id does NOT grant access: `{ids:[unshared]}` returns nothing
/// to a foreign reader.
#[tokio::test]
#[ignore]
async fn test_team_catalog_ids_lookup_unshared_returns_nothing_to_foreign() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let event = catalog_event(&author_keys, &uuid::Uuid::new_v4().to_string(), false);
let event_id = event.id;
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ok = author.send_event(event).await.expect("send");
assert!(ok.accepted, "ingest rejected: {}", ok.message);
author.disconnect().await.expect("disconnect author");
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("ids-unshared");
foreign
.subscribe(&sid, vec![Filter::new().id(event_id)])
.await
.expect("subscribe");
let events = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("collect");
assert!(
events.is_empty(),
"ids-lookup of an unshared projection must return nothing, got {:?}",
events.iter().map(|e| e.id).collect::<Vec<_>>()
);
foreign.disconnect().await.expect("disconnect foreign");
}
/// COUNT must take the per-event fallback for kind:30178 so the aggregate does
/// not leak the existence of unshared projections.
#[tokio::test]
#[ignore]
async fn test_team_catalog_count_excludes_foreign_unshared() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
let ok = author
.send_event(catalog_event(
&author_keys,
&uuid::Uuid::new_v4().to_string(),
false,
))
.await
.expect("send unshared");
assert!(ok.accepted, "unshared rejected: {}", ok.message);
let ok = author
.send_event(catalog_event(
&author_keys,
&uuid::Uuid::new_v4().to_string(),
true,
))
.await
.expect("send shared");
assert!(ok.accepted, "shared rejected: {}", ok.message);
author.disconnect().await.expect("disconnect author");
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("count");
let count_msg = serde_json::json!(["COUNT", sid, author_filter(&author_keys)]);
foreign.send_raw(&count_msg).await.expect("send COUNT");
let count = match foreign.recv_event(Duration::from_secs(5)).await {
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 must see only the shared projection, got {count}"
);
foreign.disconnect().await.expect("disconnect foreign");
}
/// Live fan-out honours the gate, and unsharing (a NIP-33 replacement that drops
/// the `shared` tag) retracts the projection from foreign readers.
#[tokio::test]
#[ignore]
async fn test_team_catalog_live_fanout_and_unshare_retracts() {
let url = relay_url();
let author_keys = Keys::generate();
let foreign_keys = Keys::generate();
let d_tag = uuid::Uuid::new_v4().to_string();
let now = Timestamp::now().as_secs();
let (t0, t1, t2) = (now.saturating_sub(2), now.saturating_sub(1), now);
// Subscribe BEFORE publishing, scoped to this author so parallel tests
// publishing their own 30178s cannot trip the leak assertion.
let mut foreign = BuzzTestClient::connect(&url, &foreign_keys)
.await
.expect("connect foreign");
let sid = sub_id("fanout");
foreign
.subscribe(&sid, vec![author_filter(&author_keys)])
.await
.expect("subscribe");
let _ = foreign
.collect_until_eose(&sid, Duration::from_secs(5))
.await
.expect("drain eose");
let mut author = BuzzTestClient::connect(&url, &author_keys)
.await
.expect("connect author");
// Unshared publish must NOT reach the foreign connection.
let ok = author
.send_event(catalog_event_at(&author_keys, &d_tag, false, t0))
.await
.expect("send unshared");
assert!(ok.accepted, "unshared rejected: {}", ok.message);
match foreign.recv_event(Duration::from_millis(750)).await {
Err(buzz_test_client::TestClientError::Timeout) => {}
Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(TEAM_CATALOG_KIND) => {
panic!("unshared projection leaked to foreign live subscription");
}
Ok(_) => {}
Err(e) => panic!("unexpected error awaiting fan-out: {e}"),
}
// Shared replacement MUST reach it.
let shared_event = catalog_event_at(&author_keys, &d_tag, true, t1);
let shared_id = shared_event.id;
let ok = author.send_event(shared_event).await.expect("send shared");
assert!(ok.accepted, "shared rejected: {}", ok.message);
let delivered = loop {
match foreign.recv_event(Duration::from_secs(5)).await {
Ok(RelayMessage::Event { event, .. }) if event.id == shared_id => break true,
Ok(_) => continue,
Err(buzz_test_client::TestClientError::Timeout) => break false,
Err(e) => panic!("unexpected error awaiting shared fan-out: {e}"),
}
};
assert!(
delivered,
"shared projection must fan out to foreign readers"
);
// Unshare: replace at the same coordinate without the tag. Subsequent
// foreign REQs must return nothing.
let ok = author
.send_event(catalog_event_at(&author_keys, &d_tag, false, t2))
.await
.expect("send unshare");
assert!(ok.accepted, "unshare rejected: {}", ok.message);
let sid_post = sub_id("post-unshare");
foreign
.subscribe(&sid_post, vec![coordinate_filter(&author_keys, &d_tag)])
.await
.expect("subscribe post");
let after = foreign
.collect_until_eose(&sid_post, Duration::from_secs(5))
.await
.expect("collect post");
assert!(
after.is_empty(),
"unsharing must retract the projection from foreign readers, got {} event(s)",
after.len()
);
author.disconnect().await.expect("disconnect author");
foreign.disconnect().await.expect("disconnect foreign");
}