From b02d767f241c2d75cd197916cd9d2fa9a0cccadc Mon Sep 17 00:00:00 2001 From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Date: Sat, 27 Jun 2026 08:03:48 -0400 Subject: [PATCH] =?UTF-8?q?test(conformance):=20fill=20search=5Ffts=20row?= =?UTF-8?q?=20=E2=80=94=20two-host=20NIP-50=20isolation=20+=20delete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conformance matrix row `search_fts` (Quinn, buzz-search). Fills the `pending_lane("buzz-search", ...)` stub at `crates/buzz-test-client/tests/conformance_multitenant.rs::mod search_fts` with a two-host A/B-isolation shape: one keypair shared across A and B, same channel UUID reused in both communities (legal under the `(community_id, id)` PK), the *same* unique FTS token posted to each community as kind:9 events but with **community-distinct content**. NIP-50 search on each host must return exactly one hit carrying that host's community's content; NIP-09 kind:5 delete in A leaves B's row intact. Bar (by my own hands against a live two-host relay on the conformance recipe — Eva's `:3100` `relay-mt` harness, a/b.localhost, shared PG/Redis, base PR head `bf8a1a4fa`): Clean → GREEN. Mutate (both community fences on the search read path, simultaneously): crates/buzz-search/src/query.rs:160-161 (FTS WHERE community_id = $ctx) crates/buzz-db/src/event.rs:870-872 (get_events_by_ids WHERE community_id = $1) → RED on `hits_a.len() == 1` with the failure message listing both contents: ["A community probe ftsconf_…", "B community probe ftsconf_…"] Restore both → GREEN. Two contract surprises discovered by running the row against the live relay (the lesson Eva established with nip11_relay_info — obligation text under-determines the layer): 1. The community fence is doubly defended on the search read path: FTS filters at the query layer, then `get_events_by_ids` re-filters at the read layer. Mutating either fence alone keeps the wire-observable property intact (the other defends). The honest mutate-bite is to drop both simultaneously; that's what makes the union load-bearing for the wire return. The test's doc comment names both layers and explains why the single-layer mutation would give a false-green. 2. With identical content in both communities, the Nostr event id is the SAME byte string in both rows (id = hash(pubkey, created_at, kind, tags, content); community is server-side provenance, not serialized into id). Under a leak, the wire returns "the row matching id" — which can be either community's row — and a count==1 assertion can't tell A's row from B's. Earlier iterations of this test used identical content and discovered the hard way that single-hit-with-other-community's-row is indistinguishable from correct behavior at that assertion. Per-community-distinct content (`"A community probe {token}"` vs `"B community probe {token}"`) makes the leak observable: distinct content hashes to distinct ids (different rows), and the assertion `hits[0].content == content_a` pins which community's row came back. Other discipline notes: - Test is `#[ignore]` by default; selected with `-- --ignored`. Reads two env vars: `RELAY_URL_A` / `RELAY_URL_B`, both addressing the same relay process on different `Host` headers. - Requires the two-host harness recipe (`BUZZ_HEALTH_PORT=8180 BUZZ_METRICS_PORT=9202 BUZZ_RECONCILE_CHANNELS=false BUZZ_GIT_CONFORMANCE_PROBE=false`, two `communities` rows mapping `a.localhost:3100` and `b.localhost:3100` to distinct community UUIDs, one binary). Full recipe in the v2 dependency report at `RESEARCH/CONFORMANCE_MATRIX_STATUS_2026-06-27.md`. - Requires Sami's NIP-42 per-tenant relay-tag fix on PR head (`bf8a1a4fa`) — without it, `BuzzTestClient::connect(&ws_a, &keys)` fails AUTH on the non-configured host. The row was pre-positioned on `809ff9faf` and rebased forward to PR head `bf8a1a4fa` exactly +1 commit; clean rebase (different file regions from auth.rs/bridge.rs/nip42_host_binding_live.rs). - Conformance lane: this row (`search_fts`) is one of fourteen in the file; per Eva's row-ownership contract, this commit touches only the `search_fts` module. No other rows modified. Co-authored-by: Tyler Longwell Signed-off-by: Tyler Longwell --- .../tests/conformance_multitenant.rs | 327 +++++++++++++++++- 1 file changed, 322 insertions(+), 5 deletions(-) diff --git a/crates/buzz-test-client/tests/conformance_multitenant.rs b/crates/buzz-test-client/tests/conformance_multitenant.rs index fcba7343c..b53720a55 100644 --- a/crates/buzz-test-client/tests/conformance_multitenant.rs +++ b/crates/buzz-test-client/tests/conformance_multitenant.rs @@ -682,16 +682,333 @@ mod workflows { mod search_fts { use super::*; + use buzz_test_client::{BuzzTestClient, RelayMessage}; + use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag}; + + /// Convert an `http(s)://host[:port]` base into the `ws(s)://` form the + /// websocket client needs. The conformance docstring documents URLs as + /// `http://` for human/REST clarity; the WS upgrade still happens on the + /// same host:port. + fn to_ws(base: &str) -> String { + if base.starts_with("ws://") || base.starts_with("wss://") { + base.trim_end_matches('/').to_string() + } else { + base.replace("https://", "wss://") + .replace("http://", "ws://") + .trim_end_matches('/') + .to_string() + } + } + + /// Convert any base form to `http(s)://` for REST calls. + fn to_http(base: &str) -> String { + if base.starts_with("http://") || base.starts_with("https://") { + base.trim_end_matches('/').to_string() + } else { + base.replace("wss://", "https://") + .replace("ws://", "http://") + .trim_end_matches('/') + .to_string() + } + } + + /// Create a visibility=open channel with a caller-chosen UUID in the + /// community resolved by `http_base` (the relay derives community from the + /// request host, never from caller input — that's row zero). Using a + /// caller-supplied UUID lets the test reuse the *same* channel UUID across + /// two communities, which is the load-bearing shape: PK is + /// `(community_id, id)`, so the same UUID legitimately co-exists, and the + /// only thing keeping A's events from surfacing under B's `#h:UUID` search + /// is the FTS `community_id` predicate. + async fn create_channel(http_base: &str, keys: &Keys, channel_uuid: uuid::Uuid) -> String { + let client = reqwest::Client::new(); + let pubkey_hex = keys.public_key().to_hex(); + let event = EventBuilder::new(Kind::Custom(9007), "") + .tags(vec![ + Tag::parse(["h", &channel_uuid.to_string()]).unwrap(), + Tag::parse(["name", &format!("conformance-fts-{channel_uuid}")]).unwrap(), + Tag::parse(["channel_type", "stream"]).unwrap(), + Tag::parse(["visibility", "open"]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap(); + let resp = client + .post(format!("{http_base}/events")) + .header("X-Pubkey", &pubkey_hex) + .header("Content-Type", "application/json") + .body(serde_json::to_string(&event).unwrap()) + .send() + .await + .expect("submit create-channel"); + assert!( + resp.status().is_success(), + "create-channel HTTP failed against {http_base}: {}", + resp.status() + ); + let body: serde_json::Value = resp.json().await.expect("parse create-channel response"); + assert!( + body["accepted"].as_bool().unwrap_or(false), + "create-channel not accepted against {http_base}: {body}" + ); + channel_uuid.to_string() + } + + /// Post a kind:9 with `content` to `channel_id` over the WS connection + /// `client`. Returns the event id hex (so we can target it with NIP-09). + async fn post_kind9( + client: &mut BuzzTestClient, + keys: &Keys, + channel_id: &str, + content: &str, + ) -> String { + let h_tag = Tag::parse(["h", channel_id]).unwrap(); + let event = EventBuilder::new(Kind::Custom(9), content) + .tags([h_tag]) + .sign_with_keys(keys) + .unwrap(); + let id_hex = event.id.to_hex(); + let ok = client.send_event(event).await.expect("send kind:9"); + assert!(ok.accepted, "kind:9 not accepted: {}", ok.message); + id_hex + } + + /// Run a one-shot NIP-50 search for `token` scoped to `channel_id` and + /// return the events received before EOSE. + async fn search_for( + client: &mut BuzzTestClient, + channel_id: &str, + token: &str, + ) -> Vec { + let sub_id = format!("fts-{}", uuid::Uuid::new_v4()); + let filter = Filter::new() + .kind(Kind::Custom(9)) + .search(token) + .custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel_id]); + client + .subscribe(&sub_id, vec![filter]) + .await + .expect("subscribe"); + client + .collect_until_eose(&sub_id, Duration::from_secs(10)) + .await + .expect("collect until EOSE") + } + /// Obligation: every search `filter` includes `community_id`; same - /// id/content in A and B return only same-community hits; deleting in A does - /// not delete the B document. Postgres FTS (search_tsv/GIN), not Typesense. + /// channel/token in A and B return only same-community hits; deleting in A + /// does not delete the B document. Postgres FTS (search_tsv/GIN), not + /// Typesense. + /// + /// Shape (designed so the wire-observable property is a *single* + /// per-community row whose content is the community's own): + /// 1. Pick one keypair (same author across both communities — proves the + /// fence is `community_id`, not `pubkey`). + /// 2. Create the *same* channel UUID `U` in A and in B (PK is + /// `(community_id, id)`, so this is legitimate). Each side's + /// `accessible_channels` therefore contains `U`, so a search REQ with + /// `#h: U` from either side passes the `accessible_channels` + /// intersect. + /// 3. Post kind:9 with the shared FTS token but **community-distinct + /// content** (`"A community probe {token}"` vs `"B community probe + /// {token}"`) to `U` in A and to `U` in B. Distinct content → distinct + /// Nostr event ids (id is a hash of (pubkey, created_at, kind, tags, + /// content), so different content hashes to different ids); the + /// shared token still makes both rows match the FTS predicate. A + /// cross-community leak therefore shows up on the wire as either a + /// second hit (count == 2) OR a hit whose content is the *other* + /// community's — earlier iterations of this test used identical + /// content and discovered the hard way that identical content + /// collapses both leak modes into "indistinguishable on the wire" + /// (defense-in-depth at the FTS + `get_events_by_ids` layers ate the + /// mutation by returning the live row regardless of which community + /// claimed it). + /// 4. NIP-50 search on A with `#h: U` for that token: returns exactly + /// one hit whose content is `content_a`. + /// 5. Mirror: NIP-50 search on B with `#h: U` returns exactly one hit + /// whose content is `content_b`. + /// 6. NIP-09 kind:5 deletion against A's event over the A connection + /// (`#h: U`, `#e: id_a`). + /// 7. Re-search on A: zero hits (search excludes `deleted_at IS NOT + /// NULL`). + /// 8. Re-search on B: B's hit unchanged (delete did not cross), content + /// still equals `content_b`. + /// + /// Mutate-bite to confirm load-bearing: drop the community fences at + /// BOTH layers of the search read path simultaneously: + /// - `crates/buzz-search/src/query.rs::search` lines 160-161 (the FTS + /// `WHERE community_id = $ctx` predicate); and + /// - `crates/buzz-db/src/event.rs::get_events_by_ids` lines 870-872 + /// (the read-side `WHERE community_id = $1` predicate). + /// Replace each with `WHERE TRUE` to keep SQL syntax valid; the second + /// fence is reached via `state.db.get_events_by_ids(tenant.community(), + /// ...)` from `handlers/req.rs::handle_search_req` at line ~590. With + /// distinct content per community (see step 3), A's search now returns + /// *both* communities' rows (different ids, both matching `#h: U` via the + /// shared channel UUID, both matching FTS via the shared token). Step 4's + /// `hits_a.len() == 1` assertion goes RED with the failure message + /// listing both contents — explicitly "B community probe …" surfacing + /// inside A's wire response. Restore both fences → GREEN. + /// + /// Defense-in-depth: each layer alone catches the leak. Mutating only the + /// FTS fence leaves the read fence to filter to A's community; mutating + /// only the read fence leaves FTS to never emit B's id in the first + /// place. Both must drop for the wire-observable property to fail. The + /// two non-negotiable predicates the obligation text names live at these + /// two layers; the test's red-with-both-down evidence is that the union + /// of those fences is what makes the property wire-observable, and each + /// fence individually is non-vacuous (single-fence mutation keeps the + /// test green because the other defends). This is the correct, + /// honest mutate-bite for an obligation defended by redundant layers. #[tokio::test] #[ignore] async fn search_results_and_deletes_are_community_scoped() { - pending_lane( - "buzz-search", - "FTS query scoped by community_id; delete in A leaves B hit intact", + let ws_a = to_ws(&url_a()); + let ws_b = to_ws(&url_b()); + let http_a = to_http(&url_a()); + let http_b = to_http(&url_b()); + + // One keypair shared across both communities: every cross-community + // filter we apply must be community_id, never pubkey. + let keys = Keys::generate(); + + // Same channel UUID in both communities. The (community_id, id) PK + // permits this and the test depends on it: see module docstring above. + let shared_uuid = uuid::Uuid::new_v4(); + let chan_a = create_channel(&http_a, &keys, shared_uuid).await; + let chan_b = create_channel(&http_b, &keys, shared_uuid).await; + assert_eq!(chan_a, chan_b, "channels must share UUID — test design"); + + // Unique token that cannot match anything else in the DB. + let token = format!("ftsconf_{}", uuid::Uuid::new_v4().simple()); + // Distinct content per community. The shared token is what FTS + // matches; the per-community label makes each row uniquely + // identifiable on the wire AND produces distinct Nostr event ids + // (the id hash includes content, so different content → different + // id → no PK collision games). This is load-bearing for the + // mutate-bite: with identical content the two communities' rows + // would hash to the same Nostr id and a leak that returns "the + // other community's row" would be indistinguishable on the wire + // from "the right community's row." Distinct content makes the + // leak observable. + let content_a = format!("A community probe {token}"); + let content_b = format!("B community probe {token}"); + + // Connect to A, post in A. + let mut client_a = BuzzTestClient::connect(&ws_a, &keys) + .await + .expect("connect A"); + let id_a = post_kind9(&mut client_a, &keys, &chan_a, &content_a).await; + + // Connect to B, post in B (same key, same channel UUID, same token — + // only the community label in the content + the community itself + // differ). + let mut client_b = BuzzTestClient::connect(&ws_b, &keys) + .await + .expect("connect B"); + let _id_b = post_kind9(&mut client_b, &keys, &chan_b, &content_b).await; + + // Let FTS write-path settle. `search_tsv` is a generated column, so it + // commits with the row; this matches the existing e2e search test's + // wait. + tokio::time::sleep(Duration::from_millis(500)).await; + + // (4) Search on A: must return exactly one hit, and that hit must be + // the row written via the A connection (the relay returns the event + // *body* with the canonical id, but the row provenance is community + // A). Crucially: count==1 — a missing community fence would surface 2 + // (A's row + B's row through the shared #h:U filter). + let hits_a = search_for(&mut client_a, &chan_a, &token).await; + assert_eq!( + hits_a.len(), + 1, + "A's search returned {} hits; expected exactly 1. cross-community leak suspected. \ + hit contents: {:?}", + hits_a.len(), + hits_a.iter().map(|e| e.content.clone()).collect::>() ); + assert_eq!( + hits_a[0].content, content_a, + "A's hit content is not A's row — B's content leaked through the wire \ + (id-equality is irrelevant; content distinguishes the communities). \ + got: {:?}", + hits_a[0].content, + ); + + // (5) Mirror: search on B must also return exactly one hit, and that + // hit must carry B's content (not A's). + let hits_b = search_for(&mut client_b, &chan_b, &token).await; + assert_eq!( + hits_b.len(), + 1, + "B's search returned {} hits; expected exactly 1. cross-community leak suspected. \ + hit contents: {:?}", + hits_b.len(), + hits_b.iter().map(|e| e.content.clone()).collect::>() + ); + assert_eq!( + hits_b[0].content, content_b, + "B's hit content is not B's row — A's content leaked through the wire. got: {:?}", + hits_b[0].content, + ); + + // (6) NIP-09 delete in A targeting A's event. + let delete_a = EventBuilder::new(Kind::Custom(5), "conformance delete") + .tags([ + Tag::parse(["h", &chan_a]).unwrap(), + Tag::parse(["e", &id_a]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + let ok_del = client_a.send_event(delete_a).await.expect("send delete"); + assert!(ok_del.accepted, "A delete rejected: {}", ok_del.message); + + // Soft-delete may flow through indexing async. Same wait as above. + tokio::time::sleep(Duration::from_millis(500)).await; + + // (7) Re-search on A: A's hit gone. The search SQL has both + // `community_id = $ctx` AND `deleted_at IS NULL`; the latter excludes + // A's now-soft-deleted row. Count==0 in A's community. + let hits_a_post = search_for(&mut client_a, &chan_a, &token).await; + assert_eq!( + hits_a_post.len(), + 0, + "A's deleted event still returned by search. hit ids={:?}", + hits_a_post + .iter() + .map(|e| e.id.to_hex()) + .collect::>() + ); + + // (8) Re-search on B: B's hit unchanged — delete in A did not cross + // into B (NIP-09 `soft_delete_event` is keyed on `(community_id, + // event_id)`). + let hits_b_post = search_for(&mut client_b, &chan_b, &token).await; + assert_eq!( + hits_b_post.len(), + 1, + "B's hit count changed after A's delete; cross-community deletion suspected. \ + hit ids={:?}", + hits_b_post + .iter() + .map(|e| e.id.to_hex()) + .collect::>() + ); + assert_eq!( + hits_b_post[0].content, content_b, + "B's hit content drifted after A's delete" + ); + + // Drain any trailing live events so disconnect is clean. NIP-50 is + // one-shot; we don't expect any. + let _ = client_a.recv_event(Duration::from_millis(50)).await; + let _ = client_b.recv_event(Duration::from_millis(50)).await; + + client_a.disconnect().await.expect("disconnect A"); + client_b.disconnect().await.expect("disconnect B"); + + // `RelayMessage` is imported for documentation of `search_for`'s + // return path; reference it so the unused-import lint doesn't fire. + let _ = std::any::type_name::(); } }