mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
e3a0e257bc
commit
6fcb8a723d
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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, ¬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:<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");
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user