Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -654,12 +654,13 @@ pub async fn submit_event(
submit_event_authed(&state, &tenant, &headers, &body, pubkey, event_id_bytes).await;

match &outcome {
SubmitOutcome::Ok { accepted, .. } => {
SubmitOutcome::Ok { accepted, kind, .. } => {
tracing::info!(
pubkey = %pubkey_hex,
route = "/events",
status = 200u16,
accepted,
kind,
"HTTP bridge request"
);
}
Expand Down Expand Up @@ -713,6 +714,7 @@ enum SubmitOutcome {
/// Ingest pipeline ran and returned a result (accepted or not).
Ok {
accepted: bool,
kind: u32,
response: Json<Value>,
},
/// JSON parse failure before ingest — log category/line/column, not msg.
Expand Down Expand Up @@ -843,6 +845,7 @@ async fn submit_event_authed(
}));
SubmitOutcome::Ok {
accepted: result.accepted,
kind: kind_u32,
response,
}
}
Expand Down Expand Up @@ -3766,4 +3769,67 @@ mod tests {
"attribution line must carry the pubkey;\nlog:\n{log}"
);
}

/// T3c — accepted Ok arm: a POST /events with a valid global text note
/// (kind:1) must include `kind` in the single attribution log line.
///
/// This is a focused regression test for block/buzz#4676: the Ok arm of
/// submit_event previously logged only `accepted`, so all accepted event
/// kinds produced identical `route:"/events", status:200, accepted:true`
/// lines.
///
/// Discriminating: if the `kind` field is removed from the `SubmitOutcome::Ok`
/// match arm or the `tracing::info!` call, this test fails.
#[test]
#[ignore = "requires Postgres"]
fn submit_event_text_note_includes_kind_in_attribution_line() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current_thread runtime");

let state = rt
.block_on(bridge_handler_test_state())
.expect("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests");

let host = {
let h = format!("bridge-attr-{}.local", uuid::Uuid::new_v4().simple());
rt.block_on(state.db.ensure_configured_community(&h))
.expect("ensure community");
h
};

let client_keys = Keys::generate();
let pubkey_hex = client_keys.public_key().to_hex();

let text_note = EventBuilder::new(Kind::TextNote, "attribution kind test")
.sign_with_keys(&client_keys)
.expect("sign text note");
let event_json = serde_json::to_vec(&text_note).expect("serialize event");

let recorder = metrics_util::debugging::DebuggingRecorder::new();
let (status, log) = metrics::with_local_recorder(&recorder, || {
run_and_capture(&rt, state, &host, &pubkey_hex, &event_json)
});

assert_eq!(
status,
axum::http::StatusCode::OK,
"valid text note must be accepted with 200"
);

let n = count_attribution_lines(&log);
assert_eq!(
n, 1,
"expected exactly 1 attribution line for accepted event, got {n};\nlog:\n{log}"
);
assert!(
log.contains("kind=1"),
"attribution line for accepted event must include kind;\nlog:\n{log}"
);
assert!(
log.contains(&pubkey_hex[..16]),
"attribution line must carry the pubkey;\nlog:\n{log}"
);
}
}
84 changes: 74 additions & 10 deletions crates/buzz-relay/src/handlers/req.rs
Original file line number Diff line number Diff line change
Expand Up @@ -848,18 +848,20 @@ fn filters_are_nip43_membership_only(filters: &[Filter]) -> bool {
}

/// Extract a channel UUID from a single filter's `#h` tag.
///
/// Only returns `Some` when the filter contains exactly one `#h` value and it
/// is a parseable UUID. Multi-value `#h` filters use NIP-01 OR semantics and
/// must be evaluated by the Rust-side `filters_match` post-filter, not pinned
/// to a single arbitrary channel in SQL.
fn extract_channel_id_from_filter(filter: &Filter) -> Option<uuid::Uuid> {
for (tag_key, tag_values) in filter.generic_tags.iter() {
let key = tag_key.to_string();
if key == "h" {
for val in tag_values {
if let Ok(id) = val.parse::<uuid::Uuid>() {
return Some(id);
}
}
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
filter.generic_tags.get(&h_tag).and_then(|vs| {
if vs.len() == 1 {
vs.iter().next()?.parse::<uuid::Uuid>().ok()
} else {
None
}
}
None
})
}

/// Convert a single NIP-01 filter into an [`EventQuery`] for the database.
Expand Down Expand Up @@ -1579,6 +1581,68 @@ mod tests {
assert_eq!(extract_channel_id_from_filters(&filters), Some(channel_id));
}

#[test]
fn extract_channel_id_from_filter_single_h() {
let channel_id = uuid::Uuid::new_v4();
let filter = filter_with_channel(channel_id);
assert_eq!(extract_channel_id_from_filter(&filter), Some(channel_id));
}

#[test]
fn extract_channel_id_from_filter_multi_h_returns_none() {
let channel_a = uuid::Uuid::new_v4();
let channel_b = uuid::Uuid::new_v4();
let filter = Filter::new().custom_tag(
SingleLetterTag::lowercase(Alphabet::H),
channel_a.to_string(),
);
let filter = filter.custom_tag(
SingleLetterTag::lowercase(Alphabet::H),
channel_b.to_string(),
);
assert_eq!(
extract_channel_id_from_filter(&filter),
None,
"multi-value #h must not be pinned to an arbitrary channel"
);
}

#[test]
fn extract_channel_id_from_filter_no_h_returns_none() {
assert_eq!(extract_channel_id_from_filter(&Filter::new()), None);
}

#[test]
fn extract_channel_id_from_filter_invalid_h_returns_none() {
let filter =
Filter::new().custom_tag(SingleLetterTag::lowercase(Alphabet::H), "not-a-uuid");
assert_eq!(extract_channel_id_from_filter(&filter), None);
}

#[test]
fn build_event_query_from_filter_multi_h_leaves_channel_id_unset() {
let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4());
let channel_a = uuid::Uuid::new_v4();
let channel_b = uuid::Uuid::new_v4();
let filter = Filter::new()
.custom_tag(
SingleLetterTag::lowercase(Alphabet::H),
channel_a.to_string(),
)
.custom_tag(
SingleLetterTag::lowercase(Alphabet::H),
channel_b.to_string(),
);

let query =
filter_to_query_params(&filter, extract_channel_id_from_filter(&filter), community);

assert_eq!(
query.channel_id, None,
"multi-value #h must not set channel_id"
);
}

#[test]
fn test_search_filter_detection() {
let search_filter = Filter::new().search("hello world");
Expand Down