test(relay): fix D2/D3 CI-failing e2e tests for draft HTTP query and fanout

D2: rewrite test_draft_not_returned_in_kindless_channel_http_query.
The HTTP bridge runs sensitive-kind gates unconditionally, so a kindless
h-tag filter triggers p_gated_filters_authorized and returns 403 for any
non-owner caller — this is fail-closed, pre-existing main behavior.  The
test now asserts 403 for the kindless case (i), then uses a mixed
kinds:[9,31234] h-tag filter as the attacker (passes all three HTTP
gates) and asserts kind:9 appears while the draft is absent (ii),
proving reader_can_receive_event is live on the bridge per-event path.

D3: add #h ch_id tag to subscription filter in
test_draft_live_fanout_reaches_author_own_subscription.  Drafts are
channel-bound events; fan_out_scoped's symmetric scoping invariant
means a global (no #h) sub never sees channel-scoped events by design.
Channel-scoping the filter routes the sub through channel_kind_index so
fan-out can match and filter_fanout_by_access passes the author through.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-14 00:21:25 -04:00
committed by Will Pfleger
co-authored by Will Pfleger
parent 985c20f885
commit 6dda6fd7c3
@@ -1731,6 +1731,10 @@ async fn test_draft_live_fanout_reaches_author_own_subscription() {
let d = uuid::Uuid::new_v4().to_string();
// Author opens a subscription with limit:0 (skip historical, live only).
// The subscription MUST be channel-scoped (#h ch_id): drafts are channel-bound
// events and fan_out_scoped's symmetric scoping invariant means a global
// (no #h) sub never sees channel-scoped events, regardless of the author-only
// gate.
let mut ac = BuzzTestClient::connect(&url, &author)
.await
.expect("connect author");
@@ -1738,6 +1742,10 @@ async fn test_draft_live_fanout_reaches_author_own_subscription() {
let filter = Filter::new()
.kind(Kind::Custom(KIND_DRAFT))
.author(author.public_key())
.custom_tag(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::H),
ch_id.as_str(),
)
.limit(0);
ac.subscribe(&sid, vec![filter])
.await
@@ -1834,13 +1842,20 @@ async fn test_draft_not_returned_in_kindless_channel_query_by_attacker() {
#[tokio::test]
#[ignore]
async fn test_draft_not_returned_in_kindless_channel_http_query() {
// HTTP bridge catchall /query mirror of the WS kindless h-tag test above.
// Exercises bridge.rs catchall path (distinct from req.rs), proving the
// reader_can_receive_event gate is live there.
// HTTP bridge /query tests for draft privacy. The bridge runs sensitive-kind
// gates unconditionally, so a kindless h-tag filter (wildcard) triggers
// p_gated_filters_authorized and returns 403 for any caller that is not the
// p-tagged owner. This is fail-closed, pre-existing main behavior — NOT a
// production defect.
//
// Non-author queries the channel with a kindless h-tag filter via HTTP
// /query. Kind:9 message must appear (positive control); kind:31234 draft
// must be absent.
// Two sub-cases:
// (i) Kindless h-tag /query as non-author → 403. The fail-closed
// outcome IS the oracle; the route simply does not exist over HTTP.
// (ii) Mixed kinds:[9, KIND_DRAFT] h-tag /query as attacker (passes all
// three HTTP gates — neither kind is p-gated, not exclusively
// author-only). Kind:9 message must appear; kind:31234 draft must
// be absent, proving reader_can_receive_event is live on the bridge
// per-event path.
let client = http_client();
let owner = Keys::generate();
let attacker = Keys::generate();
@@ -1861,31 +1876,50 @@ async fn test_draft_not_returned_in_kindless_channel_http_query() {
let (ok_m, msg_m) = submit_event_http(&client, &owner, &msg_event).await;
assert!(ok_m, "channel message must be accepted: {msg_m}");
// Kindless h-tag filter over HTTP /query as non-author.
// (i) Kindless h-tag /query as non-author must return 403 (fail-closed).
let kindless_filter = Filter::new().custom_tag(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::H),
ch_id.as_str(),
);
let results = query_events_http(
&client,
&attacker.public_key().to_hex(),
vec![kindless_filter],
)
.await;
let kindless_resp = client
.post(format!("{}/query", relay_http_url()))
.header("X-Pubkey", &attacker.public_key().to_hex())
.header("Content-Type", "application/json")
.json(&vec![kindless_filter])
.send()
.await
.expect("kindless query request");
assert_eq!(
kindless_resp.status().as_u16(),
403,
"kindless h-tag /query as non-author must be rejected with 403 (bridge fail-closed)"
);
// Kind:9 message must appear — proves the subscription is live.
// (ii) Mixed kinds:[9, KIND_DRAFT] h-tag /query as attacker.
// Both kinds pass the p-gated and exclusively-author-only HTTP gates,
// so the query reaches the per-event reader_can_receive_event guard.
let mixed_filter = Filter::new()
.kinds([Kind::Custom(9), Kind::Custom(KIND_DRAFT)])
.custom_tag(
nostr::SingleLetterTag::lowercase(nostr::Alphabet::H),
ch_id.as_str(),
);
let results =
query_events_http(&client, &attacker.public_key().to_hex(), vec![mixed_filter]).await;
// Kind:9 message must appear — proves the filter reached the data path.
assert!(
results
.iter()
.any(|e| e["id"].as_str() == Some(&msg_id.to_hex())),
"kind:9 channel message must appear in HTTP kindless query (positive control)"
"kind:9 channel message must appear in mixed-kinds HTTP query (positive control)"
);
// Draft must be absent — bridge.rs catchall author-only gate must suppress it.
// Draft must be absent — reader_can_receive_event must strip it for non-author.
assert!(
!results
.iter()
.any(|e| e["id"].as_str() == Some(&draft_id.to_hex())),
"kind:31234 draft must not appear in non-author HTTP kindless channel query"
"kind:31234 draft must not appear in mixed-kinds HTTP query for non-author"
);
}