mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
fix(search): exclude p-gated membership notices from FTS
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
parent
46ba39e45a
commit
a3f407ce96
@@ -6,7 +6,10 @@
|
|||||||
//! migration into it, exercises a scenario, and drops it. Tests are
|
//! migration into it, exercises a scenario, and drops it. Tests are
|
||||||
//! parallel-safe.
|
//! parallel-safe.
|
||||||
|
|
||||||
use buzz_core::{kind::AUTHOR_ONLY_KINDS, CommunityId};
|
use buzz_core::{
|
||||||
|
kind::{AUTHOR_ONLY_KINDS, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION},
|
||||||
|
CommunityId,
|
||||||
|
};
|
||||||
use buzz_search::{ChannelScope, SearchQuery, SearchService};
|
use buzz_search::{ChannelScope, SearchQuery, SearchService};
|
||||||
use sqlx::{postgres::PgPoolOptions, Executor, PgPool};
|
use sqlx::{postgres::PgPoolOptions, Executor, PgPool};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@@ -798,13 +801,15 @@ async fn very_long_query_is_bounded_before_pg_parse() {
|
|||||||
/// - 1059 = `KIND_GIFT_WRAP` (NIP-17 ciphertext)
|
/// - 1059 = `KIND_GIFT_WRAP` (NIP-17 ciphertext)
|
||||||
/// - 30300 = `KIND_EVENT_REMINDER` (in `AUTHOR_ONLY_KINDS`)
|
/// - 30300 = `KIND_EVENT_REMINDER` (in `AUTHOR_ONLY_KINDS`)
|
||||||
/// - 30622 = `KIND_DM_VISIBILITY` (per-viewer private hide state)
|
/// - 30622 = `KIND_DM_VISIBILITY` (per-viewer private hide state)
|
||||||
|
/// - 44100 = `KIND_MEMBER_ADDED_NOTIFICATION` (p-gated membership notice)
|
||||||
|
/// - 44101 = `KIND_MEMBER_REMOVED_NOTIFICATION` (p-gated membership notice)
|
||||||
///
|
///
|
||||||
/// All four events are inserted with the same unique token in their content
|
/// All six events are inserted with the same unique token in their content
|
||||||
/// so a single search query exercises every kind in one round-trip. Only
|
/// 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.
|
/// the kind:9 control must surface — the excluded kinds must not.
|
||||||
///
|
///
|
||||||
/// Mutate-bite: drop the `CASE WHEN kind IN (…)` from the generated column
|
/// Mutate-bite: drop the `CASE WHEN kind IN (…)` from the generated column
|
||||||
/// (revert to `to_tsvector('simple', content)`) → all four events surface →
|
/// (revert to `to_tsvector('simple', content)`) → excluded events surface →
|
||||||
/// restore.
|
/// restore.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[ignore = "requires Postgres"]
|
#[ignore = "requires Postgres"]
|
||||||
@@ -866,6 +871,32 @@ async fn excluded_kinds_are_storage_level_unsearchable() {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// kind:44100 member-added notification — p-gated and MUST NOT be searchable.
|
||||||
|
insert_event(
|
||||||
|
&pool,
|
||||||
|
c,
|
||||||
|
rand_bytes32(),
|
||||||
|
rand_bytes32(),
|
||||||
|
KIND_MEMBER_ADDED_NOTIFICATION as i32,
|
||||||
|
&format!("member added — {token}"),
|
||||||
|
None,
|
||||||
|
1_700_000_004,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// kind:44101 member-removed notification — p-gated and MUST NOT be searchable.
|
||||||
|
insert_event(
|
||||||
|
&pool,
|
||||||
|
c,
|
||||||
|
rand_bytes32(),
|
||||||
|
rand_bytes32(),
|
||||||
|
KIND_MEMBER_REMOVED_NOTIFICATION as i32,
|
||||||
|
&format!("member removed — {token}"),
|
||||||
|
None,
|
||||||
|
1_700_000_005,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let svc = SearchService::new(pool.clone());
|
let svc = SearchService::new(pool.clone());
|
||||||
let result = svc
|
let result = svc
|
||||||
.search(&SearchQuery {
|
.search(&SearchQuery {
|
||||||
@@ -891,7 +922,13 @@ async fn excluded_kinds_are_storage_level_unsearchable() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Negative (load-bearing): each excluded kind MUST NOT surface.
|
// Negative (load-bearing): each excluded kind MUST NOT surface.
|
||||||
for forbidden in [1059, 30300, 30622] {
|
for forbidden in [
|
||||||
|
1059,
|
||||||
|
30300,
|
||||||
|
30622,
|
||||||
|
KIND_MEMBER_ADDED_NOTIFICATION as i32,
|
||||||
|
KIND_MEMBER_REMOVED_NOTIFICATION as i32,
|
||||||
|
] {
|
||||||
assert!(
|
assert!(
|
||||||
!kinds.contains(&forbidden),
|
!kinds.contains(&forbidden),
|
||||||
"kind:{forbidden} MUST NOT be searchable — \
|
"kind:{forbidden} MUST NOT be searchable — \
|
||||||
|
|||||||
@@ -209,14 +209,18 @@ CREATE TABLE events (
|
|||||||
-- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext)
|
-- 1059 = KIND_GIFT_WRAP (NIP-17 ciphertext)
|
||||||
-- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth)
|
-- 30300 = KIND_EVENT_REMINDER (AUTHOR_ONLY_KINDS — defense in depth)
|
||||||
-- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state)
|
-- 30622 = KIND_DM_VISIBILITY (per-viewer private hide state)
|
||||||
|
-- 44100 = KIND_MEMBER_ADDED_NOTIFICATION (p-gated membership notice)
|
||||||
|
-- 44101 = KIND_MEMBER_REMOVED_NOTIFICATION (p-gated membership notice)
|
||||||
-- NULL tsvector never matches `@@`, so excluded rows are storage-level
|
-- NULL tsvector never matches `@@`, so excluded rows are storage-level
|
||||||
-- unsearchable. Constants kept in `buzz_core::kind` (KIND_GIFT_WRAP,
|
-- unsearchable. Constants kept in `buzz_core::kind` (KIND_GIFT_WRAP,
|
||||||
-- KIND_EVENT_REMINDER, KIND_DM_VISIBILITY); inlined here because a sqlx
|
-- KIND_EVENT_REMINDER, KIND_DM_VISIBILITY,
|
||||||
|
-- KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION); inlined
|
||||||
|
-- here because a sqlx
|
||||||
-- migration is frozen SQL and cannot import the Rust constant. If a new
|
-- 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
|
-- privacy-sensitive kind is added there, update this list and add a
|
||||||
-- regression test in `buzz-search/tests/fts_integration.rs`.
|
-- regression test in `buzz-search/tests/fts_integration.rs`.
|
||||||
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||||
CASE WHEN kind IN (1059, 30300, 30622) THEN NULL::tsvector
|
CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector
|
||||||
ELSE to_tsvector('simple', content)
|
ELSE to_tsvector('simple', content)
|
||||||
END
|
END
|
||||||
) STORED,
|
) STORED,
|
||||||
|
|||||||
@@ -203,11 +203,12 @@ CREATE TABLE events (
|
|||||||
-- community-leading btree filters BitmapAnd-ed with the GIN probe, so the
|
-- community-leading btree filters BitmapAnd-ed with the GIN probe, so the
|
||||||
-- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat:
|
-- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat:
|
||||||
-- avoid btree_gin unless EXPLAIN proves it buys something).
|
-- avoid btree_gin unless EXPLAIN proves it buys something).
|
||||||
-- Privacy: encrypted/private routing wrappers must never be discoverable
|
-- Privacy: encrypted/private routing wrappers and p-gated membership notices
|
||||||
-- through NIP-50 full-text search. NULL tsvector never matches `@@`.
|
-- must never be discoverable through NIP-50 full-text search. NULL tsvector
|
||||||
|
-- never matches `@@`.
|
||||||
-- Keep in sync with migrations/0001_initial_schema.sql.
|
-- Keep in sync with migrations/0001_initial_schema.sql.
|
||||||
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
search_tsv TSVECTOR GENERATED ALWAYS AS (
|
||||||
CASE WHEN kind IN (1059, 30300, 30622) THEN NULL::tsvector
|
CASE WHEN kind IN (1059, 30300, 30622, 44100, 44101) THEN NULL::tsvector
|
||||||
ELSE to_tsvector('simple', content)
|
ELSE to_tsvector('simple', content)
|
||||||
END
|
END
|
||||||
) STORED,
|
) STORED,
|
||||||
|
|||||||
Reference in New Issue
Block a user