diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/fts_integration.rs index 205501312..f7ff56d38 100644 --- a/crates/buzz-search/tests/fts_integration.rs +++ b/crates/buzz-search/tests/fts_integration.rs @@ -675,3 +675,125 @@ async fn channel_less_only_excludes_per_channel_events() { teardown(pool, &schema).await; } + +/// Privacy regression gate: the storage layer MUST NOT make these kinds +/// searchable. The migration's `search_tsv` generated column emits NULL +/// for excluded kinds, so a `search_tsv @@ query` probe never matches. +/// +/// Set kept in sync with the pre-rewrite skip in `handlers/event.rs:287-290` +/// on `main`: +/// - 1059 = `KIND_GIFT_WRAP` (NIP-17 ciphertext) +/// - 30300 = `KIND_EVENT_REMINDER` (in `AUTHOR_ONLY_KINDS`) +/// - 30622 = `KIND_DM_VISIBILITY` (per-viewer private hide state) +/// +/// All four events are inserted with the same unique token in their content +/// so a single search query exercises every kind in one round-trip. Only +/// the kind:9 control must surface — the three excluded kinds must not. +/// +/// Mutate-bite: drop the `CASE WHEN kind IN (…)` from the generated column +/// (revert to `to_tsvector('simple', content)`) → all four events surface → +/// restore. +#[tokio::test] +#[ignore = "requires Postgres"] +async fn excluded_kinds_are_storage_level_unsearchable() { + let (pool, schema) = setup().await; + + let c = mk_community(&pool, "privacy.example").await; + let token = "privacykinds_unique_marker_xyzzy"; + + // kind:9 control — MUST be searchable. + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + 9, + &format!("public chat — {token}"), + None, + 1_700_000_000, + ) + .await; + + // kind:1059 gift wrap (NIP-17 ciphertext) — MUST NOT be searchable. + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + 1059, + &format!("gift wrap — {token}"), + None, + 1_700_000_001, + ) + .await; + + // kind:30300 event reminder (AUTHOR_ONLY_KINDS) — MUST NOT be searchable. + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + 30300, + &format!("reminder — {token}"), + None, + 1_700_000_002, + ) + .await; + + // kind:30622 DM visibility snapshot — MUST NOT be searchable. + insert_event( + &pool, + c, + rand_bytes32(), + rand_bytes32(), + 30622, + &format!("dm visibility — {token}"), + None, + 1_700_000_003, + ) + .await; + + let svc = SearchService::new(pool.clone()); + let result = svc + .search(&SearchQuery { + community: c, + q: token.into(), + channel_scope: ChannelScope::Any, + kinds: None, + authors: None, + since: None, + until: None, + page: 1, + per_page: 10, + }) + .await + .expect("search ok"); + + let kinds: Vec = result.hits.iter().map(|h| h.kind).collect(); + + // Positive: kind:9 surfaces (control — proves the search index works at all). + assert!( + kinds.contains(&9), + "kind:9 control row MUST be searchable, got kinds={kinds:?}", + ); + + // Negative (load-bearing): each excluded kind MUST NOT surface. + for forbidden in [1059, 30300, 30622] { + assert!( + !kinds.contains(&forbidden), + "kind:{forbidden} MUST NOT be searchable — \ + privacy regression in `search_tsv` generated column. kinds={kinds:?}", + ); + } + + // Tight bound: exactly one hit (the control). Catches any future + // weakening where some-but-not-all excluded kinds surface. + assert_eq!( + result.hits.len(), + 1, + "expected exactly 1 hit (the kind:9 control), got {} (kinds={kinds:?})", + result.hits.len(), + ); + + teardown(pool, &schema).await; +} diff --git a/crates/buzz-test-client/tests/e2e_nostr_interop.rs b/crates/buzz-test-client/tests/e2e_nostr_interop.rs index 866663a3f..f26e7e267 100644 --- a/crates/buzz-test-client/tests/e2e_nostr_interop.rs +++ b/crates/buzz-test-client/tests/e2e_nostr_interop.rs @@ -971,6 +971,19 @@ async fn test_nip10_thread_reply_not_in_top_level() { #[tokio::test] #[ignore] async fn test_nip17_gift_wrap_not_searchable() { + // Privacy regression gate at the relay's actual NIP-50 search seam. + // + // Storage-level exclusion lives in the `events.search_tsv` generated + // column (migrations/0001_initial_schema.sql): a row whose `kind` is + // in the privacy skip-set yields a NULL tsvector and never matches + // `@@`. This test proves the property from the wire: REQ with a + // NIP-50 `search` filter returns the kind:9 control and does NOT + // return the kind:1059 gift wrap. + // + // The mutate-bite for the underlying property lives in + // `crates/buzz-search/tests/fts_integration.rs:: + // excluded_kinds_are_storage_level_unsearchable` + // (drops the CASE → all excluded kinds surface). let url = relay_url(); let keys_a = Keys::generate(); let keys_b = Keys::generate(); @@ -992,65 +1005,49 @@ async fn test_nip17_gift_wrap_not_searchable() { let ok = client.send_event(gift_wrap).await.expect("send gift wrap"); assert!(ok.accepted, "relay rejected gift wrap: {}", ok.message); - // 2. Send kind:9 control message with the same content. + // 2. Send kind:9 control message containing the same unique token. let ok2 = client .send_text_message(&keys_a, &channel, &unique_token, 9) .await .expect("send kind:9"); assert!(ok2.accepted, "relay rejected kind:9: {}", ok2.message); - client.disconnect().await.expect("disconnect"); + // Small delay so the FTS column (generated, in-row) is visible to readers. + tokio::time::sleep(Duration::from_millis(500)).await; - // Wait for async Typesense indexing. - tokio::time::sleep(Duration::from_secs(3)).await; + // 3. NIP-50 search via the relay (no kind filter — we want to see what + // surfaces). `h` tag scopes to our channel to keep the result set tight. + let sid = sub_id("nip17-not-searchable"); + let filter = Filter::new() + .search(&unique_token) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]); - // 3. Query Typesense DIRECTLY — bypasses all relay-level filtering. - let ts_url = - std::env::var("TYPESENSE_URL").unwrap_or_else(|_| "http://localhost:8108".to_string()); - let ts_key = std::env::var("TYPESENSE_API_KEY").unwrap_or_else(|_| "buzz_dev_key".to_string()); - - let http = reqwest::Client::new(); - let resp = http - .post(format!("{ts_url}/multi_search")) - .header("X-TYPESENSE-API-KEY", &ts_key) - .json(&serde_json::json!({ - "searches": [{ - "collection": "events", - "q": unique_token, - "query_by": "content", - "per_page": 10 - }] - })) - .send() + client + .subscribe(&sid, vec![filter]) .await - .expect("Typesense multi_search request"); + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(10)) + .await + .expect("collect until EOSE"); + + let kinds: Vec = events.iter().map(|e| e.kind.as_u16()).collect(); + + // Control: kind:9 IS searchable — proves the search path works at all. assert!( - resp.status().is_success(), - "Typesense returned {}", - resp.status() - ); - let body: serde_json::Value = resp.json().await.expect("parse Typesense response"); - - let hits = body["results"][0]["hits"].as_array().expect("hits array"); - - // Control: kind:9 IS indexed. - let has_kind9 = hits - .iter() - .any(|h| h["document"]["kind"].as_i64() == Some(9)); - assert!( - has_kind9, - "kind:9 control message not found in Typesense — indexing broken" + kinds.contains(&9), + "kind:9 control not returned by NIP-50 search — indexing broken. kinds={kinds:?}", ); - // Assertion: kind:1059 is NOT indexed. - let has_kind1059 = hits - .iter() - .any(|h| h["document"]["kind"].as_i64() == Some(1059)); + // Load-bearing: kind:1059 MUST NOT surface. assert!( - !has_kind1059, - "kind:1059 found in Typesense — gift wraps must NOT be indexed. hits: {hits:?}" + !kinds.contains(&1059), + "kind:1059 gift wrap surfaced via NIP-50 search — privacy regression in \ + the `search_tsv` generated column. kinds={kinds:?}", ); + + client.disconnect().await.expect("disconnect"); } /// Send 3 messages with varying relevance to a query, wait for indexing, then search. diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 041a452da..f9acd9e67 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -203,7 +203,23 @@ CREATE TABLE events ( -- community-leading btree filters BitmapAnd-ed with the GIN probe, so the -- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat: -- avoid btree_gin unless EXPLAIN proves it buys something). - search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED, + -- + -- Privacy kind exclusions (parity with the pre-rewrite Typesense feed — + -- old relay's `handlers/event.rs:287-290` skip set): + -- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext) + -- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth) + -- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state) + -- NULL tsvector never matches `@@`, so excluded rows are storage-level + -- unsearchable. Constants kept in `buzz_core::kind` (KIND_GIFT_WRAP, + -- KIND_EVENT_REMINDER, KIND_DM_VISIBILITY); inlined here because a sqlx + -- migration is frozen SQL and cannot import the Rust constant. If a new + -- privacy-sensitive kind is added there, update this list and add a + -- regression test in `buzz-search/tests/fts_integration.rs`. + search_tsv TSVECTOR GENERATED ALWAYS AS ( + CASE WHEN kind IN (1059, 30300, 30622) THEN NULL::tsvector + ELSE to_tsvector('simple', content) + END + ) STORED, sig BYTEA NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), channel_id UUID,