From 6fcb8a723df2ad0aa8f72df44132ee58ecdce688 Mon Sep 17 00:00:00 2001 From: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 Date: Sat, 11 Jul 2026 14:31:31 -0400 Subject: [PATCH] =?UTF-8?q?test(relay):=20harden=20NIP-37=20coverage=20mat?= =?UTF-8?q?rix=20=E2=80=94=20non-vacuous=20tests,=20Clippy=20clean?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:: 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 Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 9 + crates/buzz-db/src/lib.rs | 236 +++++++++++-- crates/buzz-relay/src/handlers/event.rs | 40 ++- crates/buzz-relay/src/handlers/ingest.rs | 48 ++- .../buzz-relay/src/handlers/side_effects.rs | 14 +- .../buzz-relay/src/mesh_status_publisher.rs | 2 +- .../buzz-test-client/tests/e2e_nip37_draft.rs | 329 +++++++++++------- 7 files changed, 489 insertions(+), 189 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e5f9dbc..a91e4a4d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index c6a088766..de2efaa9f 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -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, - expected_channel_id: Option>, ) -> 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 { + 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" + ); } } diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index d4151d7c2..90b93cd26 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -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" + ); } } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 4eefe96d0..4f3ae99cf 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -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::().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( diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 932f9d732..b7cdc51dc 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -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( diff --git a/crates/buzz-relay/src/mesh_status_publisher.rs b/crates/buzz-relay/src/mesh_status_publisher.rs index f725689f2..4285e7221 100644 --- a/crates/buzz-relay/src/mesh_status_publisher.rs +++ b/crates/buzz-relay/src/mesh_status_publisher.rs @@ -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(); diff --git a/crates/buzz-test-client/tests/e2e_nip37_draft.rs b/crates/buzz-test-client/tests/e2e_nip37_draft.rs index 23840d786..19f1e2e3a 100644 --- a/crates/buzz-test-client/tests/e2e_nip37_draft.rs +++ b/crates/buzz-test-client/tests/e2e_nip37_draft.rs @@ -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 = 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, ¬e).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::` 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"); } } })