mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
Team catalog projections (`kind:30178`) embed every member's system
prompt, so they need the same read gate personas already have: only the
author sees an unshared event. The gate was hardcoded to `kind:30175` at
six read surfaces plus the SQL pushdown, so rather than adding a second
special case it becomes kind-generic over `SHARED_GATED_KINDS = {30175,
30178}`.
## Kind 30178
New parameterized-replaceable kind, addressed by `(pubkey_o, 30178,
team_id)`. It embeds sanitized member projections instead of referencing
`kind:30175` heads — a foreign reader of a shared team could not
otherwise hydrate members whose own persona events are unshared or, for
built-ins, absent entirely. `kind:30176`'s wire body is untouched, so
device sync keeps its contract.
## Kind-generic shared gate
`buzz_core::kind` replaces `is_persona_shared_kind` /
`is_unshared_persona_event` / `persona_event_is_shared` with
`SHARED_GATED_KINDS` and the kind-agnostic `is_shared_gated_kind` /
`is_unshared_gated_event` / `event_is_shared`. Every read surface
consults the set:
| Surface | File |
|---|---|
| REQ historical delivery + `ids` lookup |
`crates/buzz-relay/src/handlers/req.rs` |
| Live fan-out | `crates/buzz-relay/src/handlers/event.rs` |
| COUNT fallback | `crates/buzz-relay/src/handlers/count.rs` |
| NIP-98 HTTP `/query`, `/count`, `/search` |
`crates/buzz-relay/src/api/bridge.rs` |
| Pre-`LIMIT` SQL pushdown | `crates/buzz-db/src/event.rs` |
The SQL clause generalizes from `kind != 30175` to `kind NOT IN (...)`
bound from `SHARED_GATED_KINDS`, still applied before `ORDER BY … LIMIT`
so a page of newer private events cannot starve an older shared one off
the candidate set. `EventQuery::persona_reader` is renamed
`shared_gated_reader` and `needs_persona_filtering` to
`needs_shared_gate_filtering` to match.
Because the `buzz-core` rename has consumers outside the relay, the four
desktop call sites of `persona_event_is_shared` travel with it:
`desktop/src-tauri/src/commands/personas/pending.rs`,
`desktop/src-tauri/src/event_sync.rs`, and two in
`desktop/src-tauri/src/managed_agents/persona_events.rs`. Each call is
unchanged apart from the name — the persona `shared` projection behaves
exactly as before.
## Ingest validation
`validate_persona_envelope` splits into two reusable pieces —
`validate_shared_tag` (exactly-two-element `["shared","true"]`, at most
one occurrence) and `single_bounded_d_tag` (exactly one `d` tag,
non-empty, `<=64` chars, no ASCII control characters or whitespace).
`validate_team_catalog_envelope` composes both; personas additionally
keep the slug grammar `^[a-z0-9][a-z0-9_-]{0,63}$`.
`kind:30178` deliberately does **not** get the slug grammar. Team ids
are UUIDs or built-in identifiers such as `builtin-team:welcome`, and
the colon is not slug-legal; rewriting ids to fit would break NIP-33
addressing against the team's own `kind:30176` head. The non-empty and
exactly-one checks are load-bearing regardless — without them generic
NIP-33 storage maps a missing `d` onto `(pubkey_o, 30178, "")` and every
team overwrites its predecessor.
The exact two-element `shared` shape is enforced because the SQL
visibility clause is JSONB containment (`tags @>
'[["shared","true"]]'`), which would match a three-element superset such
as `["shared","true","extra"]`.
`kind:30178` is also added to the `Scope::UsersWrite` allowlist and to
`is_global_only_kind`, so a stray `h` tag cannot channel-scope an
owner-authored definition.
## Deferred
`kind:30176` is deliberately not a gate member. Its writers never emit
`shared`, so catalog opt-in semantics do not describe it — it needs
owner-private reads driven by an authenticated principal set, tracked as
a separate follow-up.
## Tests
- 19 new `ingest.rs` unit tests covering the 30178 envelope (UUID and
colon `d` tags, 64-char boundary, non-ASCII bound,
empty/valueless/duplicate/missing `d`, embedded newline, `shared`
false/three-element/duplicate, scope and global-only membership).
- Persona regressions for the valueless `["d"]` shapes, since the
`d`-tag helper is shared by both validators.
- Existing `kind.rs` gate tests generalized and extended to assert the
gate applies to 30178 as it does to 30175.
- New `crates/buzz-test-client/tests/e2e_team_catalog.rs`: 9 WS-level
tests over a live relay covering author reads of unshared heads, foreign
omission from REQ, `ids`-lookup denial, COUNT existence-leak, share and
unshare transitions, and the mixed-kind filter case.
- `.github/workflows/ci.yml` adds `--test e2e_team_catalog` to the Relay
E2E job so the new suite runs.
## Docs
`docs/nips/NIP-AP.md` gains a "Team catalog projection: kind:30178"
section and an "Ingest validation: kind:30178" subsection, records the
gate as kind-generic, documents 30178 deletion vs. unshare semantics,
and adds a security note that sharing a team exposes every member's
instructions even when that member's own `kind:30175` head is unshared.
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
485 lines
17 KiB
Rust
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");
|
|
}
|