test(relay): harden NIP-37 coverage matrix — non-vacuous tests, Clippy clean

Fix all remaining quality gaps identified in the pre-Thufir review:

Clippy (FIX-11):
- Remove 17 needless borrows in e2e_nip37_draft.rs (auto-fixed)
- sort_by → sort_by_key in tie-break test (auto-fixed)
- while_let_loop → while let loop in removed-member fan-out test
- splitn(3, ':').next() → split(':').next() in ingest.rs NIP-09 guard

DB tests (CRITICAL-A-test, FIX-4, FIX-5):
- Add build_test_draft_at helper (explicit timestamp control)
- Add query_draft_head helper (reusable across tests)
- Expand draft_is_confined_to_its_community: full A/B lifecycle (insert →
  query → replace → tombstone) with scoped head assertions after each step;
  uses same d_tag in both communities to prove community_id is the real
  isolation boundary
- Add draft_channel_binding_is_immutable_across_sequential_calls: sequential
  rebind attempt on an already-bound address → DraftChannelMismatch; stored
  head still v1 after failed rebind
- Add post-race head query to concurrent_different_channel_drafts_one_wins_one_loses:
  assert exactly one live head bound to the winning channel after the race

E2E tests (CRITICAL-B-test, FIX-6, FIX-7, FIX-8, FIX-9, FIX-10):
- Add test_nip09_a_tag_deletion_of_draft_is_rejected: kind:5 a-tag targeting
  31234:<pubkey>:<d> must be rejected; draft must still be live head after
- FIX-7: Expand workflow tripwire to evaluate the actual dispatch predicate
  (is_workflow_execution_kind && is_command_kind && AUTHOR_ONLY_KINDS) for
  kind:31234 (→ false) and kind:9 (→ true, positive control)
- FIX-8: DM test — replace silent return with panic! on missing channel_id;
  use strictly increasing timestamps (base-2, base-1, base) to guarantee
  deterministic ordering across v1/v2/tombstone
- FIX-6: test_draft_not_returned_in_kindless_channel_query — rewrite to use
  attacker (not owner) as requester; the author-only gate strips drafts from
  non-author queries, not from the author's own channel queries
- FIX-9: Removed-member live fan-out — use author(owner) subscription so the
  probe event (owner draft) actually matches the filter and exercises the gate
- FIX-10: Rename test_draft_tenant_confinement_channel_from_different_community
  → inline note + pointer to the existing test_draft_rejected_nonexistent_channel_h_tag
  (the old name was misleading; true cross-tenant confinement is the DB test)

CI (FIX-CI):
- Wire buzz-db NIP-37 draft Postgres tests to backend-integration job

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-14 00:21:25 -04:00
committed by Will Pfleger
co-authored by Will Pfleger
parent e3a0e257bc
commit 6fcb8a723d
7 changed files with 489 additions and 189 deletions
+9
View File
@@ -620,6 +620,15 @@ jobs:
run: cargo test --profile ci -p buzz-test-client --test e2e_nip37_draft -- --ignored
env:
RELAY_URL: ws://localhost:3000
- name: NIP-37 draft wrap DB tests
# DB-layer tests for NIP-37 draft wraps: tenant confinement (A/B
# independent heads across communities), immutable channel-binding
# (sequential rebind → DraftChannelMismatch), and race-guard
# (concurrent writes → exactly one winner + one DraftChannelMismatch).
# These run directly against Postgres without a relay process.
run: cargo test --profile ci -p buzz-db -- draft --ignored
env:
DATABASE_URL: postgres://buzz:buzz_dev@localhost:5432/buzz
- name: Upload relay log
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+198 -38
View File
@@ -2792,12 +2792,16 @@ impl Db {
/// by its d-tag, regardless of which channel it was submitted to. The `channel_id`
/// parameter is stored on the new row for query scoping but does not affect replacement.
///
/// **Immutable-channel enforcement (`expected_channel_id`):** When `Some(expected)`,
/// the function checks — **inside the advisory lock, before the stale-ordering step** —
/// that the current head's `channel_id` equals `expected`. If it differs the
/// transaction is rolled back and an `Err(DbError::DraftChannelMismatch)` is returned.
/// Pass `None` to skip the check (all parameterized-replaceable kinds except
/// kind:31234 draft wraps).
/// **Immutable-channel enforcement (kind:31234):** When the event is a
/// kind:31234 draft wrap, the function checks — **inside the advisory lock,
/// before the stale-ordering step** — that the current head's `channel_id`
/// equals the incoming `channel_id`. If it differs the transaction is rolled
/// back and an `Err(DbError::DraftChannelMismatch)` is returned.
///
/// The check is inferred from `event.kind` so no caller can supply a separate
/// "expected" value that differs from the "stored" value — the API is
/// structurally non-bypassable. All other parameterized-replaceable kinds
/// skip the check.
///
/// Note: `replace_addressable_event()` keys on `channel_id` because it serves
/// relay-signed NIP-29 group metadata (kind 39000–39002) where the relay is the
@@ -2809,7 +2813,6 @@ impl Db {
event: &nostr::Event,
d_tag: &str,
channel_id: Option<Uuid>,
expected_channel_id: Option<Option<Uuid>>,
) -> Result<(StoredEvent, bool)> {
let kind_i32 = buzz_core::kind::event_kind_i32(event);
let pubkey_bytes = event.pubkey.to_bytes();
@@ -2893,12 +2896,13 @@ impl Db {
// Immutable channel-binding check for kind:31234 draft wraps.
//
// Runs **inside** the advisory lock so the read-then-reject is atomic:
// no concurrent writer can slip a different-channel head between the
// SELECT and our rollback.
if let Some(expected) = expected_channel_id {
// Inferred from event.kind — no separate "expected" parameter means no caller
// can pass expected=A while storing channel_id=B. Runs **inside** the advisory
// lock so the read-then-reject is atomic: no concurrent writer can slip a
// different-channel head between the SELECT and our rollback.
if buzz_core::kind::event_kind_u32(event) == buzz_core::kind::KIND_DRAFT {
if let Some((_, _, head_channel_id)) = &existing {
if *head_channel_id != expected {
if *head_channel_id != channel_id {
tx.rollback().await?;
return Err(DbError::DraftChannelMismatch);
}
@@ -4210,12 +4214,49 @@ mod tests {
.unwrap()
}
/// Build a kind:31234 event at a specific Unix timestamp (seconds).
fn build_test_draft_at(
keys: &nostr::Keys,
d_tag: &str,
channel_id: &Uuid,
ts_secs: u64,
) -> nostr::Event {
nostr::EventBuilder::new(nostr::Kind::Custom(31234), "")
.tags([
nostr::Tag::parse(["d", d_tag]).unwrap(),
nostr::Tag::parse(["k", "9"]).unwrap(),
nostr::Tag::parse(["h", &channel_id.to_string()]).unwrap(),
])
.custom_created_at(nostr::Timestamp::from(ts_secs))
.sign_with_keys(keys)
.unwrap()
}
/// Query the live head for a (community, kind:31234, author, d_tag) address.
async fn query_draft_head(
db: &Db,
community_id: CommunityId,
author: &nostr::Keys,
d_tag: &str,
) -> Vec<buzz_core::StoredEvent> {
db.query_events(&EventQuery {
kinds: Some(vec![31234_i32]),
authors: Some(vec![author.public_key().to_bytes().to_vec()]),
d_tag: Some(d_tag.to_string()),
..EventQuery::for_community(community_id)
})
.await
.expect("query draft head")
}
/// Tenant confinement: a kind:31234 written to community A must NOT be
/// visible via community B's `replace_parameterized_event` — the query is
/// scoped by `community_id`.
///
/// This tests the DB layer directly (two real communities) and does not
/// require a second relay instance.
/// require a second relay instance. The full lifecycle is exercised:
/// insert → query → replace → tombstone, with each community's head
/// checked independently at every step.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn draft_is_confined_to_its_community() {
@@ -4225,37 +4266,135 @@ mod tests {
let keys = nostr::Keys::generate();
let ch_a = Uuid::new_v4();
let ch_b = Uuid::new_v4();
// Same d_tag for both communities — proves community_id is the real
// isolation boundary, not an accidental d_tag split.
let d_tag = Uuid::new_v4().to_string();
// Insert a draft into community A.
let draft = build_test_draft(&keys, &d_tag, &ch_a);
let (_, inserted) = db
.replace_parameterized_event(community_a, &draft, &d_tag, Some(ch_a), Some(Some(ch_a)))
.await
.expect("insert draft into A");
assert!(inserted, "draft must be inserted into A");
let now = nostr::Timestamp::now().as_secs();
// Reading the same (pubkey, d_tag) from community B must see no existing head
// → expected_channel_id check must pass (no head → no conflict).
// Step 1: insert draft v1 into community A.
let draft_a_v1 = build_test_draft_at(&keys, &d_tag, &ch_a, now - 3);
let (_, inserted_a) = db
.replace_parameterized_event(community_a, &draft_a_v1, &d_tag, Some(ch_a))
.await
.expect("insert draft v1 into A");
assert!(inserted_a, "draft v1 must be inserted into A");
// Step 2: same (pubkey, d_tag) in community B is an independent address.
let draft_b_v1 = build_test_draft_at(&keys, &d_tag, &ch_b, now - 3);
let (_, inserted_b) = db
.replace_parameterized_event(community_b, &draft_b_v1, &d_tag, Some(ch_b))
.await
.expect("same (pubkey, d_tag) in community B must succeed as independent address");
assert!(
inserted_b,
"draft must be stored as a new independent head in B"
);
// Verify each community sees only its own head.
let heads_a = query_draft_head(&db, community_a, &keys, &d_tag).await;
let heads_b = query_draft_head(&db, community_b, &keys, &d_tag).await;
assert_eq!(
heads_a.len(),
1,
"A must have exactly one head after v1 insert"
);
assert_eq!(
heads_b.len(),
1,
"B must have exactly one head after v1 insert"
);
assert_eq!(
heads_a[0].event.id, draft_a_v1.id,
"A head must be A's draft v1"
);
assert_eq!(
heads_b[0].event.id, draft_b_v1.id,
"B head must be B's draft v1"
);
// Step 3: replace A's draft with v2 — B's head must not change.
let draft_a_v2 = build_test_draft_at(&keys, &d_tag, &ch_a, now - 1);
let (_, replaced_a) = db
.replace_parameterized_event(community_a, &draft_a_v2, &d_tag, Some(ch_a))
.await
.expect("replace A draft with v2");
assert!(replaced_a, "A draft v2 must supersede v1");
let heads_a_after = query_draft_head(&db, community_a, &keys, &d_tag).await;
let heads_b_after = query_draft_head(&db, community_b, &keys, &d_tag).await;
assert_eq!(
heads_a_after[0].event.id, draft_a_v2.id,
"A head must be v2 after replace"
);
assert_eq!(
heads_b_after[0].event.id, draft_b_v1.id,
"B head must still be B's v1 — A's replace must not touch B"
);
// Step 4: tombstone A's draft — B's head must still be unchanged.
let tombstone_a = build_test_draft_at(&keys, &d_tag, &ch_a, now + 1);
let (_, tomb_inserted) = db
.replace_parameterized_event(community_a, &tombstone_a, &d_tag, Some(ch_a))
.await
.expect("tombstone A draft");
assert!(tomb_inserted, "tombstone must supersede v2");
let heads_a_tomb = query_draft_head(&db, community_a, &keys, &d_tag).await;
let heads_b_tomb = query_draft_head(&db, community_b, &keys, &d_tag).await;
assert_eq!(
heads_a_tomb[0].event.id, tombstone_a.id,
"A head must be the tombstone"
);
assert_eq!(
heads_b_tomb[0].event.id, draft_b_v1.id,
"B head must still be B's v1 — A's tombstone must not touch B"
);
}
/// Immutable channel binding — sequential rebind attempt: once a draft
/// address (community, author, d_tag) is bound to channel A, a subsequent
/// call with the same address but channel B must fail with
/// `DraftChannelMismatch`. The advisory-lock read-then-reject must fire
/// on a plain sequential call — no concurrent race required.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn draft_channel_binding_is_immutable_across_sequential_calls() {
let db = setup_db().await;
let community = CommunityId::from_uuid(make_community(&db.pool).await);
let keys = nostr::Keys::generate();
let ch_a = Uuid::new_v4();
let ch_b = Uuid::new_v4();
let draft_b = build_test_draft(&keys, &d_tag, &ch_b);
let d_tag = Uuid::new_v4().to_string();
let now = nostr::Timestamp::now().as_secs();
// Bind the address to ch_a.
let draft_v1 = build_test_draft_at(&keys, &d_tag, &ch_a, now - 1);
let (_, inserted) = db
.replace_parameterized_event(community, &draft_v1, &d_tag, Some(ch_a))
.await
.expect("initial insert for ch_a must succeed");
assert!(inserted, "draft must be inserted on first call");
// Attempt to rebind the same address to ch_b — must be rejected.
let draft_v2 = build_test_draft_at(&keys, &d_tag, &ch_b, now + 1);
let result = db
.replace_parameterized_event(
community_b,
&draft_b,
&d_tag,
Some(ch_b),
Some(Some(ch_b)),
)
.replace_parameterized_event(community, &draft_v2, &d_tag, Some(ch_b))
.await;
assert!(
result.is_ok(),
"same (pubkey, d_tag) in community B must be an independent address; got: {result:?}"
matches!(result, Err(DbError::DraftChannelMismatch)),
"sequential rebind to a different channel must return DraftChannelMismatch; got: {result:?}"
);
let (_, b_inserted) = result.unwrap();
assert!(
b_inserted,
"draft must be stored as a new independent head in B"
// The stored head must still be the original v1 (bound to ch_a).
let heads = query_draft_head(&db, community, &keys, &d_tag).await;
assert_eq!(heads.len(), 1, "exactly one live head after failed rebind");
assert_eq!(
heads[0].event.id, draft_v1.id,
"live head must still be v1 bound to ch_a — rebind must not overwrite it"
);
}
@@ -4264,6 +4403,8 @@ mod tests {
///
/// One must win (the first committer) and the second must get
/// `DraftChannelMismatch` — never a corrupt split-brain state.
/// Post-race: the live head for the winning address must be exactly one
/// event bound to the winning channel.
#[tokio::test]
#[ignore = "requires Postgres"]
async fn concurrent_different_channel_drafts_one_wins_one_loses() {
@@ -4284,7 +4425,7 @@ mod tests {
let d_a = d_tag.clone();
let ev_a = draft_a.clone();
let fut_a = async move {
db_a.replace_parameterized_event(community, &ev_a, &d_a, Some(ch_a), Some(Some(ch_a)))
db_a.replace_parameterized_event(community, &ev_a, &d_a, Some(ch_a))
.await
};
@@ -4292,7 +4433,7 @@ mod tests {
let d_b = d_tag.clone();
let ev_b = draft_b.clone();
let fut_b = async move {
db_b.replace_parameterized_event(community, &ev_b, &d_b, Some(ch_b), Some(Some(ch_b)))
db_b.replace_parameterized_event(community, &ev_b, &d_b, Some(ch_b))
.await
};
@@ -4321,5 +4462,24 @@ mod tests {
"exactly one concurrent write must fail with DraftChannelMismatch; \
res_a={res_a:?}, res_b={res_b:?}"
);
// Post-race invariant: the address must have exactly one live head,
// bound to the winning channel.
let winning_ch = if matches!(&res_a, Ok((_, true))) {
ch_a
} else {
ch_b
};
let heads = query_draft_head(&db, community, &keys, &d_tag).await;
assert_eq!(
heads.len(),
1,
"address must have exactly one live head after the race"
);
assert_eq!(
heads[0].channel_id,
Some(winning_ch),
"live head must be bound to the winning channel"
);
}
}
+37 -3
View File
@@ -2434,17 +2434,51 @@ mod tests {
///
/// A draft must never arrive in the workflow engine, regardless
/// of future refactoring in the dispatch path.
///
/// The test exercises the **actual dispatch predicate** used in
/// `dispatch_persistent_event_inner` (the `if` condition that
/// gates the workflow-spawn block) — not just membership in the
/// constant. It compares a kind:31234 draft against a kind:9
/// channel message to prove the gate is live: the predicate must
/// evaluate to `false` for the draft and `true` for the message.
#[test]
fn draft_kind_is_excluded_from_workflow_dispatch_by_author_only_guard() {
// This is the exact predicate that gates workflow dispatch in
// `dispatch_persistent_event`. Changing AUTHOR_ONLY_KINDS
// without updating this test would turn it red immediately.
// Step 1 — membership check: AUTHOR_ONLY_KINDS must contain KIND_DRAFT.
// Changing the constant without updating this test turns it red.
assert!(
buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&buzz_core::kind::KIND_DRAFT),
"KIND_DRAFT must be in AUTHOR_ONLY_KINDS — the workflow-dispatch guard \
at event.rs:514 (`!AUTHOR_ONLY_KINDS.contains(&kind_u32)`) relies on \
this to suppress draft events from reaching the workflow engine"
);
// Step 2 — dispatch-predicate evaluation for a normal user event
// (is_relay_workflow_msg = false for any user-submitted event, so
// that branch does not affect the predicate). This mirrors the
// exact `if` condition that gates the workflow spawn in
// `dispatch_persistent_event_inner`.
let should_trigger_workflow = |kind_u32: u32| -> bool {
// is_relay_workflow_msg = false for user-submitted events.
!buzz_core::kind::is_workflow_execution_kind(kind_u32)
&& !buzz_core::kind::is_command_kind(kind_u32)
&& kind_u32 != buzz_core::kind::KIND_GIFT_WRAP
&& !buzz_core::kind::AUTHOR_ONLY_KINDS.contains(&kind_u32)
};
assert!(
!should_trigger_workflow(buzz_core::kind::KIND_DRAFT),
"workflow dispatch predicate must return false for kind:31234 — \
a draft wrap must NEVER reach the workflow engine"
);
// Positive control: a plain channel message (kind:9) must pass the
// same predicate so we know the gate is not just trivially always-false.
let kind_channel_message: u32 = 9;
assert!(
should_trigger_workflow(kind_channel_message),
"workflow dispatch predicate must return true for kind:9 channel messages \
(positive control) — the gate must be live, not trivially always-false"
);
}
}
}
+32 -16
View File
@@ -2020,6 +2020,33 @@ async fn ingest_event_inner(
"invalid: deletion events must reference exactly one target via e or a tag (got e={e_count}, a={a_count})"
)));
}
// NIP-37 draft wraps (kind:31234) do NOT support NIP-09 a-tag deletion.
// Accepting it would erase the head row, after which a write with a
// different channel could bypass the immutable-binding invariant.
// The correct NIP-37 deletion mechanism is an empty-content tombstone
// (kind:31234 with "" content) that keeps the address visible and
// preserves the channel binding.
if a_count == 1 {
let targets_draft = event.tags.iter().any(|t| {
if t.kind().to_string() == "a" {
t.content()
.and_then(|v| v.split(':').next())
.and_then(|k| k.parse::<u32>().ok())
.map(|k| k == KIND_DRAFT)
.unwrap_or(false)
} else {
false
}
});
if targets_draft {
return Err(IngestError::Rejected(
"invalid: NIP-09 a-tag deletion of kind:31234 draft wraps is not supported; \
use an empty-content tombstone (kind:31234 with empty content) instead"
.into(),
));
}
}
}
if kind_u32 == KIND_STREAM_MESSAGE_EDIT {
@@ -2395,25 +2422,14 @@ async fn ingest_event_inner(
)));
}
// For kind:31234 draft wraps, pass the expected channel_id so that
// replace_parameterized_event enforces the immutable-binding invariant
// atomically inside the advisory lock. All other parameterized-
// replaceable kinds pass None (no channel binding constraint).
let expected_channel_id = if kind_u32 == KIND_DRAFT {
Some(channel_id)
} else {
None
};
// for kind:31234 draft wraps atomically inside the advisory lock by
// inferring the check from event.kind — no separate expected_channel_id
// parameter needed. All other parameterized-replaceable kinds have no
// channel binding constraint.
state
.db
.replace_parameterized_event(
tenant.community(),
&event,
&d_tag,
channel_id,
expected_channel_id,
)
.replace_parameterized_event(tenant.community(), &event, &d_tag, channel_id)
.await
.map_err(|e| match e {
buzz_db::DbError::DraftChannelMismatch => IngestError::Rejected(
+13 -1
View File
@@ -1984,6 +1984,18 @@ async fn handle_a_tag_deletion(
let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key());
match kind_num {
// NIP-37 draft wraps use an empty-content tombstone (kind:31234 with ""
// content) for deletion — they do NOT use NIP-09 a-tag soft-deletion.
// Accepting a NIP-09 a-tag soft-delete would erase the head row, after
// which a different-channel write would see no existing head and bypass
// the immutable-binding invariant. Reject with an error so the
// caller can surface it as a validation failure.
buzz_core::kind::KIND_DRAFT => {
return Err(anyhow::anyhow!(
"NIP-09 a-tag deletion of kind:31234 draft wraps is not supported; \
use an empty-content tombstone (kind:31234 with empty content) instead"
));
}
buzz_core::kind::KIND_WORKFLOW_DEF => {
// Try UUID first (workflow_id); fall back to name-based lookup.
if let Ok(wf_id) = uuid::Uuid::parse_str(d_tag) {
@@ -3037,7 +3049,7 @@ pub async fn publish_dm_visibility_snapshot(
let (stored, was_inserted) = state
.db
.replace_parameterized_event(tenant.community(), &event, &viewer_hex, None, None)
.replace_parameterized_event(tenant.community(), &event, &viewer_hex, None)
.await?;
if was_inserted {
dispatch_persistent_event(
@@ -225,7 +225,7 @@ pub async fn publish_mesh_status(
let (stored, was_inserted) = state
.db
.replace_parameterized_event(tenant.community(), &event, &d_tag, None, None)
.replace_parameterized_event(tenant.community(), &event, &d_tag, None)
.await?;
if was_inserted {
let relay_pubkey_hex = state.relay_keypair.public_key().to_hex();
+199 -130
View File
@@ -265,7 +265,7 @@ async fn test_draft_rejected_missing_h_tag() {
let client = http_client();
let keys = Keys::generate();
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -288,7 +288,7 @@ async fn test_draft_rejected_duplicate_h_tag() {
let keys = Keys::generate();
let d = uuid::Uuid::new_v4().to_string();
let ch = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -311,7 +311,7 @@ async fn test_draft_rejected_non_uuid_h_tag() {
let client = http_client();
let keys = Keys::generate();
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -530,7 +530,7 @@ async fn test_draft_accepted_future_expiration() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -549,7 +549,7 @@ async fn test_draft_rejected_missing_d_tag() {
let client = http_client();
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["k", "9"]).unwrap(),
Tag::parse(["h", &ch_id]).unwrap(),
@@ -567,7 +567,7 @@ async fn test_draft_rejected_empty_d_tag() {
let client = http_client();
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", ""]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -588,7 +588,7 @@ async fn test_draft_rejected_oversized_d_tag() {
let ch_id = create_open_channel(&owner).await;
// D_TAG_MAX_LEN is 1024 bytes in buzz-db. Use 1025 'a' chars.
let d_tag = "a".repeat(1025);
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d_tag]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -611,7 +611,7 @@ async fn test_draft_rejected_duplicate_d_tag() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["d", &d]).unwrap(),
@@ -632,7 +632,7 @@ async fn test_draft_rejected_missing_k_tag() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["h", &ch_id]).unwrap(),
@@ -651,7 +651,7 @@ async fn test_draft_rejected_duplicate_k_tag() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -672,7 +672,7 @@ async fn test_draft_rejected_malformed_k_tag_non_decimal() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "0x9"]).unwrap(),
@@ -695,7 +695,7 @@ async fn test_draft_rejected_k_tag_leading_zero() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "09"]).unwrap(),
@@ -715,7 +715,7 @@ async fn test_draft_rejected_k_tag_out_of_range() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "65536"]).unwrap(), // u16::MAX + 1
@@ -735,7 +735,7 @@ async fn test_draft_rejected_p_tag() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -779,7 +779,7 @@ async fn test_draft_rejected_expiration_in_past() {
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let event = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -892,7 +892,7 @@ async fn test_draft_same_second_tie_break_lower_id_wins() {
// Schnorr nonce were deterministic).
let mut candidates: Vec<nostr::Event> = Vec::new();
for i in 0u32..20 {
let e = EventBuilder::new(Kind::Custom(KIND_DRAFT), &fake_nip44_v2())
let e = EventBuilder::new(Kind::Custom(KIND_DRAFT), fake_nip44_v2())
.tags([
Tag::parse(["d", &d]).unwrap(),
Tag::parse(["k", "9"]).unwrap(),
@@ -907,11 +907,15 @@ async fn test_draft_same_second_tie_break_lower_id_wins() {
}
// Deduplicate by ID (should never trigger, but kept for safety).
candidates.dedup_by_key(|e| e.id.to_hex());
if candidates.len() < 2 {
// Extremely unlikely — skip rather than fail.
return;
}
candidates.sort_by(|a, b| a.id.to_hex().cmp(&b.id.to_hex()));
// The unique _tiebreak tag guarantees distinct event hashes — this must
// always produce at least 2 distinct IDs. A silent return here would
// allow the test to pass without ever exercising the tie-break logic.
assert!(
candidates.len() >= 2,
"expected at least 2 distinct candidate IDs with unique _tiebreak tags; got {}",
candidates.len()
);
candidates.sort_by_key(|a| a.id.to_hex());
let lowest = candidates.first().unwrap().clone();
let highest = candidates.last().unwrap().clone();
@@ -1259,14 +1263,16 @@ async fn test_draft_attacker_cannot_retrieve_by_known_d_tag_exclusive_ws() {
async fn test_draft_attacker_cannot_retrieve_draft_by_d_tag_in_mixed_kinds_ws() {
// An attacker who knows the victim's d-tag value submits
// kinds=[31234] + #d=[d_value] + author=[victim]. Must get CLOSED, not the event.
// This also covers the kindless #d path: explicit kind:31234 is strictly worse
// for the attacker than kindless, so if the kind-specific filter is blocked the
// kindless variant is also blocked by the author-only gate.
//
// Positive control: a public kind:30023 (long-form article) published under
// the SAME `d` must be returned by kinds=[30023,31234]+author+#d — proving
// the filter itself is not broken, only the draft is gated.
let url = relay_url();
let client = http_client();
let victim = Keys::generate();
let attacker = Keys::generate();
let ch_id = create_open_channel(&victim).await;
// Use the same `d` value for both the draft AND the kind:30023 control.
let d = uuid::Uuid::new_v4().to_string();
let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2());
@@ -1274,14 +1280,15 @@ async fn test_draft_attacker_cannot_retrieve_draft_by_d_tag_in_mixed_kinds_ws()
let (ok, msg) = submit_event_http(&client, &victim, &draft).await;
assert!(ok, "victim draft must be accepted: {msg}");
// Also publish a public kind:0 so we can verify the filter would return
// other results if drafts were not gated.
let profile = EventBuilder::new(Kind::Metadata, "{}")
// Publish a public kind:30023 with the same d-tag — this is the positive
// control that proves kinds=[30023,31234]+#d is a live filter, not a no-op.
let article = EventBuilder::new(Kind::Custom(30023), "long-form article content")
.tags([Tag::parse(["d", &d]).unwrap()])
.sign_with_keys(&victim)
.unwrap();
let profile_id = profile.id;
let (ok_p, msg_p) = submit_event_http(&client, &victim, &profile).await;
assert!(ok_p, "victim profile must be accepted: {msg_p}");
let article_id = article.id;
let (ok_a, msg_a) = submit_event_http(&client, &victim, &article).await;
assert!(ok_a, "victim article must be accepted: {msg_a}");
let mut ac = BuzzTestClient::connect(&url, &attacker)
.await
@@ -1321,27 +1328,32 @@ async fn test_draft_attacker_cannot_retrieve_draft_by_d_tag_in_mixed_kinds_ws()
other => panic!("expected CLOSED for #d+kind:31234 filter, got: {other:?}"),
}
// Positive control: a kindless #d filter must NOT return drafts but also
// must not CLOSED (it's a valid filter for other kinds).
let sid2 = sub_id("d-kindless-check");
let filter2 = Filter::new().custom_tag(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::D),
d.as_str(),
);
// Mixed-kinds positive control: kinds=[30023,31234] + author + #d=[same d].
// The kind:30023 article must appear; the kind:31234 draft must NOT.
// Using explicit kinds avoids the p-gated wildcard guard.
let sid2 = sub_id("d-mixed-control");
let filter2 = Filter::new()
.kinds(vec![Kind::Custom(30023), Kind::Custom(KIND_DRAFT)])
.author(victim.public_key())
.custom_tag(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::D),
d.as_str(),
);
ac.subscribe(&sid2, vec![filter2])
.await
.expect("subscribe kindless");
let kindless_results = ac
.expect("subscribe mixed kinds");
let mixed_results = ac
.collect_until_eose(&sid2, Duration::from_secs(5))
.await
.expect("collect kindless");
.expect("collect mixed kinds");
assert!(
!kindless_results.iter().any(|e| e.id == draft_id),
"kindless #d filter must not expose victim's draft to attacker"
mixed_results.iter().any(|e| e.id == article_id),
"kind:30023 article under same d must appear in [30023,31234]+#d filter (positive control)"
);
assert!(
!mixed_results.iter().any(|e| e.id == draft_id),
"kind:31234 draft must not appear in [30023,31234]+#d filter for attacker"
);
// The profile (kind:0) has no d-tag so it won't appear here either; that's fine.
// The important thing is the draft is not in the results.
let _ = profile_id; // referenced for completeness
ac.disconnect().await.expect("disconnect");
}
@@ -1610,47 +1622,27 @@ async fn test_draft_live_fanout_only_reaches_author() {
ac.disconnect().await.expect("disconnect");
}
// ─── Tenant confinement ───────────────────────────────────────────────────────
// ─── Nonexistent / alien channel rejection ────────────────────────────────────
// NOTE: test_draft_rejected_nonexistent_channel_h_tag (at the top of this file)
// already covers this: a draft with a valid-UUID h-tag pointing to no live
// channel is rejected. That test is the single authoritative nonexistent-channel
// guard. True cross-community tenant confinement is covered at the DB layer by
// `draft_is_confined_to_its_community` (requires Postgres, wired to CI).
// ─── Kindless channel query — draft privacy ───────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_draft_tenant_confinement_channel_from_different_community() {
// Channel UUID that exists in one community must not be valid in another.
// This test requires a second community/tenant to be reachable — if the
// relay runs as a single tenant the test is a no-op (channel simply won't
// exist from the adversarial requester's perspective).
//
// We simulate by using a randomly generated UUID that is almost certain
// not to exist in any community: submitting a draft to that UUID must
// be rejected by the nonexistent-channel check.
let client = http_client();
let adversary = Keys::generate();
let alien_channel_id = uuid::Uuid::new_v4().to_string();
let d = uuid::Uuid::new_v4().to_string();
let event = build_draft(&adversary, &d, "9", &alien_channel_id, &fake_nip44_v2());
let (accepted, msg) = submit_event_http(&client, &adversary, &event).await;
assert!(
!accepted,
"draft to alien/nonexistent channel must be rejected"
);
assert!(
msg.contains("channel") || msg.contains("member") || msg.contains("not found"),
"unexpected message: {msg}"
);
}
// ─── Workflow exclusion ───────────────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_draft_not_returned_in_kindless_channel_query() {
// A kindless channel filter must not return draft events even when the
// requester is the author. Draft content is private — never leaked via
// channel-scoped queries.
async fn test_draft_not_returned_in_kindless_channel_query_by_attacker() {
// A kindless channel h-tag filter submitted by a non-author must never
// return the author's draft. The attacker has channel membership (the
// channel is open) and subscribes to all events in the channel — they
// must receive the owner's public messages but not their drafts.
let url = relay_url();
let client = http_client();
let owner = Keys::generate();
let attacker = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
@@ -1668,11 +1660,11 @@ async fn test_draft_not_returned_in_kindless_channel_query() {
let (ok_m, msg_m) = submit_event_http(&client, &owner, &msg_event).await;
assert!(ok_m, "channel message must be accepted: {msg_m}");
// Query by channel h-tag, no kind filter.
let mut c = BuzzTestClient::connect(&url, &owner)
// Attacker queries by channel h-tag, no kind filter.
let mut c = BuzzTestClient::connect(&url, &attacker)
.await
.expect("connect");
let sid = sub_id("ch-kindless");
.expect("connect attacker");
let sid = sub_id("ch-kindless-attacker");
let filter = Filter::new().custom_tag(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::H),
ch_id.as_str(),
@@ -1683,15 +1675,15 @@ async fn test_draft_not_returned_in_kindless_channel_query() {
.await
.expect("collect");
// Channel message must appear.
// Channel message must appear (positive control — attacker can see public messages).
assert!(
results.iter().any(|e| e.id == msg_id),
"channel message must appear in h-tag query (positive control)"
"channel message must appear in attacker's h-tag query (positive control)"
);
// Draft must be absent — drafts are author-private, not channel-public.
// Draft must be absent — author-only gate must strip it before delivery.
assert!(
!results.iter().any(|e| e.id == draft_id),
"draft must not be returned by a kindless channel h-tag filter"
"draft must not be returned by a kindless channel h-tag filter to a non-author"
);
c.disconnect().await.expect("disconnect");
}
@@ -1702,40 +1694,43 @@ async fn test_draft_not_returned_in_kindless_channel_query() {
#[ignore]
async fn test_draft_not_indexed_in_fts_search() {
// A kind:31234 has NULL search_tsv at the storage layer, so NIP-50 search
// must never surface it — even when the draft's content would otherwise
// contain the search token, and even when the requester is the author.
// must never surface it — even when the requester is the author.
//
// The test explicitly uses kinds=[1,31234] in the search filter so the query
// cannot be satisfied by the read-gate alone: if kind:31234 had a non-NULL
// tsvector matching the token, the relay would return it to the authorized
// author. NULL tsvector is the only thing that hides it.
let client = http_client();
let victim = Keys::generate();
let attacker = Keys::generate();
let ch_id = create_open_channel(&victim).await;
let d = uuid::Uuid::new_v4().to_string();
// Use a unique marker as the search token and include it in the kind:1 note
// content (positive control) and as the "label" of the draft's fake
// NIP-44 payload (which is base64 — not searchable at the storage level).
let marker = format!("nip37fts_probe_{}", uuid::Uuid::new_v4().simple());
// Unique word token — used as kind:1 content (FTS-indexed) and as the
// search query for both kinds.
let token = format!("nip37probe{}", uuid::Uuid::new_v4().simple());
// Kind:1 control note — MUST appear in FTS results.
let note = EventBuilder::new(Kind::TextNote, &marker)
let note = EventBuilder::new(Kind::TextNote, &token)
.sign_with_keys(&victim)
.unwrap();
let note_id = note.id;
let (ok_note, msg_note) = submit_event_http(&client, &victim, &note).await;
assert!(ok_note, "control note must be accepted: {msg_note}");
// Kind:31234 draft — MUST NOT appear in FTS results.
// Content is valid NIP-44 v2 ciphertext regardless of the marker (storage
// layer enforces NULL tsvector for kind:31234 before content is considered).
// Kind:31234 draft — NIP-44 v2 content (relay validates). Storage migration
// sets search_tsv = NULL for all kind:31234 rows, so even a theoretically
// searchable payload must not surface in FTS results.
let draft = build_draft(&victim, &d, "9", &ch_id, &fake_nip44_v2());
let draft_id = draft.id;
let (ok_d, msg_d) = submit_event_http(&client, &victim, &draft).await;
assert!(ok_d, "draft must be accepted: {msg_d}");
// Search as the author with an explicit kinds=[1,31234] filter to ensure we
// probe the storage-layer null-tsvector exclusion, not just the read gate.
// Search as the author with explicit kinds=[1,31234]. The kind:31234 draft
// is excluded by NULL search_tsv; the kind:1 note IS found.
let search_filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Custom(KIND_DRAFT)])
.search(&marker)
.search(&token)
.limit(50);
let results =
query_events_http(&client, &victim.public_key().to_hex(), vec![search_filter]).await;
@@ -1756,7 +1751,7 @@ async fn test_draft_not_indexed_in_fts_search() {
// Attacker-side check: search with kinds=[1,31234] as attacker.
let attacker_filter = Filter::new()
.kinds(vec![Kind::TextNote, Kind::Custom(KIND_DRAFT)])
.search(&marker)
.search(&token)
.limit(50);
let attacker_results = query_events_http(
&client,
@@ -1800,6 +1795,69 @@ async fn test_nip11_advertises_nip37_not_nip40() {
);
}
// ─── NIP-09 a-tag deletion guard ─────────────────────────────────────────────
#[tokio::test]
#[ignore]
async fn test_nip09_a_tag_deletion_of_draft_is_rejected() {
// kind:5 with a single `a` tag targeting `31234:<pubkey>:<d>` must be
// rejected at ingest. The relay must never let a kind:5 event act as an
// escape hatch to clear a draft's immutable channel binding.
let client = http_client();
let owner = Keys::generate();
let ch_id = create_open_channel(&owner).await;
let d = uuid::Uuid::new_v4().to_string();
// First publish a draft so there is something to attempt to delete.
let draft = build_draft(&owner, &d, "9", &ch_id, &fake_nip44_v2());
let (ok_d, msg_d) = submit_event_http(&client, &owner, &draft).await;
assert!(
ok_d,
"draft must be accepted before the deletion attempt: {msg_d}"
);
// Build kind:5 with a single a-tag targeting the draft's NIP-33 address.
let a_coord = format!("31234:{}:{}", owner.public_key().to_hex(), d);
let deletion = EventBuilder::new(Kind::EventDeletion, "")
.tags([Tag::parse(["a", &a_coord]).unwrap()])
.sign_with_keys(&owner)
.unwrap();
let (accepted, msg) = submit_event_http(&client, &owner, &deletion).await;
assert!(
!accepted,
"kind:5 a-tag deletion targeting kind:31234 must be rejected; relay said: {msg}"
);
assert!(
msg.contains("31234")
|| msg.contains("draft")
|| msg.contains("not supported")
|| msg.contains("invalid"),
"rejection message must explain why; got: {msg}"
);
// The draft must still exist as a live head — the rejected kind:5 must not
// have modified anything.
let filter = Filter::new()
.kind(nostr::Kind::Custom(KIND_DRAFT))
.author(owner.public_key())
.custom_tag(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::D),
d.as_str(),
);
let results = query_events_http(&client, &owner.public_key().to_hex(), vec![filter]).await;
assert_eq!(
results.len(),
1,
"draft must still have one live head after rejected kind:5 deletion"
);
assert_eq!(
results[0]["id"].as_str().unwrap(),
draft.id.to_hex(),
"live head must still be the original draft — kind:5 must not have altered it"
);
}
// ─── DM channel path ─────────────────────────────────────────────────────────
#[tokio::test]
@@ -1839,26 +1897,36 @@ async fn test_draft_accepted_and_replaced_in_dm_channel() {
.expect("channel_id in DM response")
.to_string()
} else {
// Fallback: skip test if DM channel UUID is not surfaced here.
return;
panic!(
"DM open response must contain a `response:{{...}}` payload with channel_id; \
got message: {msg:?} (full body: {body})"
);
};
// Alice submits a draft bound to the DM channel UUID.
// Timestamps are strictly increasing to guarantee deterministic ordering.
let d = uuid::Uuid::new_v4().to_string();
let now = nostr::Timestamp::now().as_secs();
let base = nostr::Timestamp::now().as_secs();
let v1 = build_draft_at(
&alice,
&d,
"9",
&dm_channel_id,
&fake_nip44_v2(),
nostr::Timestamp::from(now - 1),
nostr::Timestamp::from(base - 2),
);
let (ok1, msg1) = submit_event_http(&client, &alice, &v1).await;
assert!(ok1, "draft v1 to DM channel must be accepted: {msg1}");
// Replace with a newer version.
let v2 = build_draft(&alice, &d, "9", &dm_channel_id, &fake_nip44_v2());
// Replace with a strictly newer version (base - 1 > base - 2).
let v2 = build_draft_at(
&alice,
&d,
"9",
&dm_channel_id,
&fake_nip44_v2(),
nostr::Timestamp::from(base - 1),
);
let v2_id = v2.id;
let (ok2, msg2) = submit_event_http(&client, &alice, &v2).await;
assert!(
@@ -1885,8 +1953,14 @@ async fn test_draft_accepted_and_replaced_in_dm_channel() {
"v2 must be the head after replacement"
);
// Tombstone the draft.
let tomb = build_tombstone(&alice, &d, "9", &dm_channel_id, nostr::Timestamp::now());
// Tombstone the draft (base > base - 1, so this supersedes v2).
let tomb = build_tombstone(
&alice,
&d,
"9",
&dm_channel_id,
nostr::Timestamp::from(base),
);
let tomb_id = tomb.id;
let (ok_t, msg_t) = submit_event_http(&client, &alice, &tomb).await;
assert!(ok_t, "tombstone in DM channel must be accepted: {msg_t}");
@@ -1985,27 +2059,27 @@ async fn test_removed_member_cannot_read_drafts_after_removal() {
);
}
// Live fan-out: owner posts a new draft to the same channel; removed member
// must not receive it via a pre-existing WS subscription.
// Live fan-out: owner posts a new draft to the channel. The removed
// member must not receive it via a pre-existing WS subscription, even
// if they filter on author(owner) — only current channel members may
// receive owner's drafts.
let mut removed_client = BuzzTestClient::connect(&url, &member)
.await
.expect("connect removed member");
let sid = sub_id("removed-fanout");
// Subscribe to the owner's drafts — if channel membership is properly
// enforced, the relay must CLOSE or simply not deliver owner's new draft
// to the removed member.
let live_filter = Filter::new()
.kind(nostr::Kind::Custom(KIND_DRAFT))
.author(member.public_key())
.author(owner.public_key())
.limit(0); // skip historical; only live
// This subscription itself may be CLOSED (author-only with no membership);
// either outcome is correct.
let _ = removed_client.subscribe(&sid, vec![live_filter]).await;
let _ = removed_client
.collect_until_eose(&sid, Duration::from_secs(2))
.await;
// Owner submits a new draft for the removed member's address.
// (Won't be accepted since owner != member, but any live fanout to removed
// member would indicate a leak.)
// Verify no draft events arrive for the removed member.
// Owner submits a new draft — this is the live probe event.
let owner_draft = build_draft(
&owner,
&uuid::Uuid::new_v4().to_string(),
@@ -2015,19 +2089,14 @@ async fn test_removed_member_cannot_read_drafts_after_removal() {
);
let owner_draft_id = owner_draft.id;
let (ok_od, _) = submit_event_http(&client, &owner, &owner_draft).await;
// Owner's own draft succeeds; check it doesn't reach removed member.
// Owner's own draft must be accepted; verify it doesn't reach removed member.
if ok_od {
let _ = tokio::time::timeout(Duration::from_secs(2), async {
loop {
match removed_client.recv_event(Duration::from_secs(1)).await {
Ok(RelayMessage::Event { event, .. }) => {
if event.id == owner_draft_id {
panic!(
"removed member received a draft via live fan-out after removal"
);
}
}
_ => break,
while let Ok(RelayMessage::Event { event, .. }) =
removed_client.recv_event(Duration::from_secs(1)).await
{
if event.id == owner_draft_id {
panic!("removed member received owner's draft via live fan-out after removal");
}
}
})