mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
test(relay): pin multi-coordinate deletion refusal for owner records
The pure kind:30180 envelope tests sat in ingest's postgres_tests module without #[ignore], so no lane ran them; select them in test-unit by name. Ingest's one-target rule refuses a second `a` tag before the per-coordinate privacy loop, so pin that refusal end to end and unit-test the loop directly as defense in depth for any future multi-target deletion. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
2
Justfile
2
Justfile
@@ -531,7 +531,7 @@ test-unit:
|
||||
# are infra-free; config parses env into a struct and never connects.
|
||||
# `^config::` is anchored so it does not also select nip_fi_config.
|
||||
cargo nextest run -p buzz-relay --lib --bin buzz-relay \
|
||||
-E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
|
||||
-E 'test(/^api::admin::/) + test(/^push_runtime::/) + test(/^operator_listener::tests::/) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^storage_sweep::tests::/) + test(/^nip_fi_core::tests::/) + test(/^nip_fi_http::tests::/) + test(/^nip_fi_config::tests::/) + test(/^readiness::tests::/) + test(/^router::tests::/) + test(/^api::parse_query_tests::/) + test(/^api::git::transport::off_mode_precedence_tests::/) + test(/^audio::join::tests::/) + test(/^audio::handler::tests::/) + test(/^nip_fi_gate::tests::/) + test(/^nip_fi_session::tests::/) + test(/^nip_fi_shadow::tests::/) + test(/^nip_fi_shadow_session::tests::/) + test(/^startup_steps::tests::/) + test(/^telemetry::tests::/) + test(/^config::tests::/) + test(=state::tests::neither_a_confirmed_inactive_community_nor_a_failed_lookup_admits_the_socket) + test(=state::tests::periodic_revalidation_disconnects_inside_the_fenced_callback) + test(=state::tests::bare_community_disconnect_fails_closed_when_the_fence_is_unavailable) + test(=connection::tests::send_loop_sends_policy_close_when_community_is_archived) + test(=state::tests::community_disconnect_then_nip_fi_keeps_community_deleted_reason) + test(=state::tests::disconnect_community_wins_reason_losing_nip_fi_does_not_enqueue_frame) + test(=handlers::req::tests::timed_out_historical_read_deregisters_before_closed) + test(=handlers::req::tests::superseded_timeout_leaves_replacement_intact) + test(=handlers::req::tests::search_claim_retires_live_and_yields_to_replacement) + test(=handlers::req::tests::concurrent_claims_and_stale_teardowns_keep_the_last_owner) + test(=handlers::req::tests::timeout_closed_is_emitted_before_a_replacement_can_claim) + test(=handlers::req::tests::revoke_then_replacement_keeps_replacement_whole) + test(=handlers::req::tests::claims_after_connection_cleanup_are_refused) + test(=handlers::req::tests::dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::revoke_dropped_terminal_frame_cancels_connection) + test(=handlers::req::tests::count_fallback_covers_filters_that_can_match_private_deletions) + test(=handlers::ingest::postgres_tests::owner_record_envelope_accepts_agent_and_team_records) + test(=handlers::ingest::postgres_tests::owner_record_envelope_rejects_malformed_d_and_h) + test(=handlers::ingest::postgres_tests::owner_record_is_global_users_write) + (kind(bin) & (test(/^tests::/) + test(/^composition_tests::/) + test(/^env_filter_tests::/)) - test(/^tests::postgres_tests::/))'
|
||||
# Note on audio::join::tests scope: the full suite is infra-free (no DB,
|
||||
# no Redis). The infra-free audio/FI regression witnesses — bootstrap
|
||||
# ordering barrier, CommitConfirmed arm, pending-close invisibility,
|
||||
|
||||
@@ -435,13 +435,7 @@ pub async fn validate_standard_deletion_event(
|
||||
let actor_bytes = effective_message_author(event, &state.relay_keypair.public_key());
|
||||
let target_ids = extract_target_event_ids(event);
|
||||
|
||||
// Every coordinate names its target's kind, so the `k` rule applies to
|
||||
// each `a` tag, including ones the handler below does not act on: an
|
||||
// untagged deletion naming an author-only coordinate would be stored
|
||||
// publicly and reveal that address.
|
||||
for target_kind in a_tag_target_kinds(event) {
|
||||
check_deletion_privacy_k_tag(event, target_kind)?;
|
||||
}
|
||||
check_coordinate_privacy_k_tags(event)?;
|
||||
|
||||
if !has_e_tag(event) {
|
||||
// a-tag deletion: verify author owns the addressable event
|
||||
@@ -493,6 +487,19 @@ pub async fn validate_standard_deletion_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every coordinate names its target's kind, so the `k` rule applies to each
|
||||
/// `a` tag, including ones the deletion handler does not act on: an untagged
|
||||
/// deletion naming an author-only coordinate would be stored publicly and
|
||||
/// reveal that address. Ingest's one-target rule makes a second coordinate
|
||||
/// unreachable today; checking all of them is defense in depth should
|
||||
/// multi-target deletion ever be allowed.
|
||||
fn check_coordinate_privacy_k_tags(event: &Event) -> anyhow::Result<()> {
|
||||
for target_kind in a_tag_target_kinds(event) {
|
||||
check_deletion_privacy_k_tag(event, target_kind)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Kinds named by the event's `a` tag coordinates (`<kind>:<pubkey>:<d>`).
|
||||
fn a_tag_target_kinds(event: &Event) -> impl Iterator<Item = u32> + '_ {
|
||||
event.tags.iter().filter_map(|tag| match tag.as_slice() {
|
||||
@@ -3902,6 +3909,25 @@ pub async fn publish_nipia_unarchived(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn coordinate_privacy_check_covers_a_private_second_coordinate() {
|
||||
let keys = nostr::Keys::generate();
|
||||
let owner = keys.public_key().to_hex();
|
||||
let event = EventBuilder::new(Kind::from(5u16), "")
|
||||
.tags([
|
||||
Tag::parse(["a", &format!("30023:{owner}:public")]).expect("tag"),
|
||||
Tag::parse(["a", &format!("30180:{owner}:agent:{owner}")]).expect("tag"),
|
||||
])
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign");
|
||||
let err = check_coordinate_privacy_k_tags(&event).expect_err("private second coordinate");
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("deletion of an author-only kind 30180 event requires"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nip43_progress_logs_every_interval_inside_the_startup_sweep_only() {
|
||||
use Nip43ReconciliationPurpose::{Bootstrap, Maintenance};
|
||||
|
||||
@@ -511,6 +511,34 @@ async fn test_owner_record_deletion_requires_k_tag() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A deletion naming a private address as its second `a` tag is refused by
|
||||
/// the one-target rule before any privacy check runs, for `30180` and for an
|
||||
/// existing private kind (`30300`).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_deletion_with_private_second_coordinate_is_refused() {
|
||||
let client = aod::http_client();
|
||||
let owner = Keys::generate();
|
||||
let pubkey = owner.public_key().to_hex();
|
||||
let public = aod::tag(&["a", &format!("30023:{pubkey}:public")]);
|
||||
for private in [
|
||||
coordinate(&owner, &agent_d()),
|
||||
format!("30300:{pubkey}:reminder"),
|
||||
] {
|
||||
let tags = vec![public.clone(), aod::tag(&["a", &private])];
|
||||
let (accepted, msg) =
|
||||
aod::submit(&client, &owner, &aod::build_deletion(&owner, tags)).await;
|
||||
assert!(
|
||||
!accepted,
|
||||
"two-coordinate deletion naming {private} must be refused"
|
||||
);
|
||||
assert!(
|
||||
msg.starts_with("invalid: deletion events must reference exactly one target"),
|
||||
"unexpected refusal for {private}: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A coordinate deletion removes the record for its owner and is visible to
|
||||
/// the owner and nobody else on WS REQ, `ids`, WS COUNT, HTTP `/query` and
|
||||
/// HTTP `/count`. A `k`-tagged deletion of a coordinate that never held a
|
||||
|
||||
Reference in New Issue
Block a user