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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 51 additions & 1 deletion TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,55 @@ cargo test -p buzz-test-client -- --ignored

---

## Search Backend Test Matrix

NIP-50 search runs behind the `BUZZ_SEARCH_BACKEND` flag (`typesense` |
`postgres` | `disabled`, **default `postgres`**). The relay enforces two
non-negotiable gates that must hold *identically across all three backends*:

- **Gate #1 — no visibility widening.** A search must never return an event
the caller couldn't otherwise read. The auth/`#p` gates in `handle_req` run
*before* the backend call, and `handle_search_req` re-applies `filters_match`
to every hit, so the post-filter is backend-independent by construction.
- **Gate #2 — `disabled` fails closed.** With `BUZZ_SEARCH_BACKEND=disabled`,
every NIP-50 query returns empty — no content leaks regardless of how well it
would otherwise match.

The e2e search suite lives in
[`crates/buzz-test-client/tests/e2e_nostr_interop.rs`](crates/buzz-test-client/tests/e2e_nostr_interop.rs)
(all `#[ignore]`, require a running relay). The relay's backend is surfaced to
the test process via `BUZZ_TEST_BACKEND`; backend-specific tests early-return
(skip) when it doesn't match, so the same suite is safe against any backend.

| Test | typesense | postgres | disabled | Proves |
|------|:---------:|:--------:|:--------:|--------|
| `test_nip50_search_returns_results_and_eose` | ✅ | ✅ | skip¹ | search finds a matching message; one-shot (no live events post-EOSE) |
| `test_nip50_search_relevance_order` | ✅ | ✅ | skip¹ | rank-based ordering (proximity beats recency) |
| `test_nip50_search_cross_author_isolation` | ✅ | ✅ | skip¹ | **gate #1**: outsider gets 0 hits from a *private* channel |
| `test_nip17_gift_wrap_not_searchable` | ✅ | ✅ | skip¹ | **gate #1**: kind:1059 never surfaces via search; kind:9 control does |
| `test_nip50_search_disabled_fails_closed` | skip² | skip² | ✅ | **gate #2**: a would-match query returns empty under `disabled` |
| `test_nip50_search_empty_results` | ✅ | ✅ | ✅ | a non-matching query yields EOSE with no events |
| `test_nip50_search_mixed_filters_rejected` | ✅ | ✅ | ✅ | mixed search + non-search filters → CLOSED |
| `test_nip17_gift_wrap_accepted` / `_requires_p_filter` / `_recipient_receives` | ✅ | ✅ | ✅ | NIP-17 accept/auth paths (backend-independent) |

¹ Hit-dependent — asserts a non-empty result, so it is only run against a real
backend. ² Asserts empty — only meaningful, and only run, under `disabled`.

To exercise the matrix, launch a relay per backend (set `BUZZ_SEARCH_BACKEND`)
and run the suite with `BUZZ_TEST_BACKEND` set to match. For a real backend:

```bash
BUZZ_SEARCH_BACKEND=postgres buzz-relay & # or typesense / disabled
RELAY_URL=ws://localhost:3000 BUZZ_TEST_BACKEND=postgres \
cargo test -p buzz-test-client --test e2e_nostr_interop -- --ignored
```

For `disabled`, run only the fail-closed + result-independent tests — the
hit-dependent ones skip themselves, so a full `--ignored` run is also safe but
exercises fewer assertions.

---

## Live Local Relay

The fastest way to exercise the relay end-to-end is to build the release
Expand Down Expand Up @@ -267,7 +316,8 @@ out of the box with `just setup` or `just relay`. Common overrides:
| `RELAY_URL` | `ws://localhost:3000` | Advertised in NIP-11 / NIP-42 challenges. **Note: no `BUZZ_` prefix.** |
| `DATABASE_URL` | `postgres://buzz:buzz_dev@localhost:5432/buzz` | |
| `REDIS_URL` | `redis://localhost:6379` | |
| `TYPESENSE_URL` | `http://localhost:8108` | |
| `TYPESENSE_URL` | `http://localhost:8108` | Only used when `BUZZ_SEARCH_BACKEND=typesense` |
| `BUZZ_SEARCH_BACKEND` | `postgres` | NIP-50 search backend: `typesense`, `postgres`, or `disabled` (fails closed) |
| `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) |
| `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect |
| `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup |
Expand Down
8 changes: 8 additions & 0 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,14 @@ impl Db {
Self { pool }
}

/// Returns a clone of the underlying connection pool.
///
/// Used by the Postgres FTS backend in `buzz-search` so it can run
/// queries against the same pool as the rest of the relay.
pub fn pool(&self) -> PgPool {
self.pool.clone()
}

/// Run pending database migrations.
pub async fn migrate(&self) -> Result<()> {
migration::run_migrations(&self.pool).await
Expand Down
42 changes: 31 additions & 11 deletions crates/buzz-db/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ mod tests {
fn embedded_migrator_contains_all_schema_migrations() {
let migrations: Vec<_> = MIGRATOR.iter().collect();

assert_eq!(migrations.len(), 3);
assert_eq!(migrations.len(), 4);
assert_eq!(migrations[0].version, 1);
assert_eq!(&*migrations[0].description, "initial schema");
assert!(
Expand Down Expand Up @@ -160,6 +160,20 @@ mod tests {
&& migrations[2].sql.as_str().contains("idx_events_not_before"),
"third migration should add the NIP-ER reminder columns and index"
);

assert_eq!(migrations[3].version, 4);
assert_eq!(&*migrations[3].description, "search fts");
assert!(
migrations[3]
.sql
.as_str()
.contains("idx_events_content_fts")
&& migrations[3]
.sql
.as_str()
.contains("to_tsvector('simple', content)"),
"fourth migration should add the expression GIN index for FTS"
);
}

async fn connect_test_pool() -> PgPool {
Expand Down Expand Up @@ -192,24 +206,30 @@ mod tests {
.expect("read applied migrations")
}

/// Returns `schema/schema.sql` with the NIP-ER reminder DDL removed, so it
/// models a pre-stack deployment whose `events` table lacks the reminder
/// columns and index. The strip is asserted: if the snapshot text drifts so
/// these fragments no longer match, the test fails loudly rather than
/// silently loading a snapshot that already carries the reminder columns
/// (which would make migration 0003 collide on re-add).
/// Returns `schema/schema.sql` with the NIP-ER reminder DDL and the
/// search-FTS index removed, so it models a pre-stack deployment whose
/// `events` table lacks the reminder columns and the FTS expression
/// index. The strip is asserted: if the snapshot text drifts so these
/// fragments no longer match, the test fails loudly rather than silently
/// loading a snapshot that already carries them (which would make
/// migration 0003 or 0004 collide on re-add).
fn pre_reminder_schema_snapshot() -> String {
const REMINDER_COLUMNS: &str = " not_before BIGINT,\n delivered_at BIGINT,\n";
const REMINDER_INDEX: &str = "CREATE INDEX idx_events_not_before ON events (not_before)\n WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;\n";
const FTS_INDEX: &str =
"CREATE INDEX idx_events_content_fts ON events USING GIN (to_tsvector('simple', content));\n";

assert!(
SCHEMA_SQL.contains(REMINDER_COLUMNS) && SCHEMA_SQL.contains(REMINDER_INDEX),
"schema.sql reminder DDL drifted; update pre_reminder_schema_snapshot to match"
SCHEMA_SQL.contains(REMINDER_COLUMNS)
&& SCHEMA_SQL.contains(REMINDER_INDEX)
&& SCHEMA_SQL.contains(FTS_INDEX),
"schema.sql reminder/FTS DDL drifted; update pre_reminder_schema_snapshot to match"
);

SCHEMA_SQL
.replace(REMINDER_COLUMNS, "")
.replace(REMINDER_INDEX, "")
.replace(FTS_INDEX, "")
}

#[tokio::test]
Expand All @@ -220,7 +240,7 @@ mod tests {

run_migrations(&pool).await.expect("run migrations");

assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]);
assert_eq!(applied_versions(&pool).await, vec![1, 2, 3, 4]);
let events_exists = sqlx::query_scalar::<_, bool>(
"SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'events')",
)
Expand Down Expand Up @@ -253,7 +273,7 @@ mod tests {

run_migrations(&pool).await.expect("baseline migrations");

assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]);
assert_eq!(applied_versions(&pool).await, vec![1, 2, 3, 4]);
let allowlist_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pubkey_allowlist")
.fetch_one(&pool)
.await
Expand Down
63 changes: 33 additions & 30 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -719,9 +719,10 @@ async fn handle_bridge_search(
continue;
}

// Build Typesense filter — push channel scope + NIP-01 constraints.
// Build the backend-neutral search filter — push channel scope +
// NIP-01 constraints into structured fields.
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
let filter_channel_scope =
let filter_channel_scope: Vec<String> =
if let Some(vs) = filter.generic_tags.get(&h_tag).filter(|vs| !vs.is_empty()) {
let valid: Vec<String> = vs
.iter()
Expand All @@ -732,39 +733,41 @@ async fn handle_bridge_search(
if valid.is_empty() {
continue; // All #h values inaccessible — skip filter.
}
format!("channel_id:=[{}]", valid.join(","))
valid
} else {
channel_scope.clone()
};

let mut filter_parts = vec![filter_channel_scope];
if let Some(ref kinds) = filter.kinds {
if !kinds.is_empty() {
let kind_vals: Vec<String> = kinds.iter().map(|k| k.as_u16().to_string()).collect();
filter_parts.push(format!("kind:=[{}]", kind_vals.join(",")));
}
}
if let Some(ref authors) = filter.authors {
if !authors.is_empty() {
let author_vals: Vec<String> = authors.iter().map(|a| a.to_hex()).collect();
filter_parts.push(format!("pubkey:=[{}]", author_vals.join(",")));
let kinds_vec: Vec<u16> = filter
.kinds
.as_ref()
.map(|ks| ks.iter().map(|k| k.as_u16()).collect())
.unwrap_or_default();
let authors_vec: Vec<String> = filter
.authors
.as_ref()
.map(|auths| auths.iter().map(|a| a.to_hex()).collect())
.unwrap_or_default();
let since_secs = filter.since.map(|t| t.as_secs() as i64);
let until_secs = filter.until.map(|t| t.as_secs() as i64);

let search_query = match buzz_search::SearchQuery::new(search_text, filter_channel_scope) {
Ok(q) => q
.with_kinds(kinds_vec)
.with_authors(authors_vec)
.with_since(since_secs)
.with_until(until_secs)
.with_page(1)
.with_per_page(limit),
Err(e) => {
// Upstream guards (the per-filter h_tag validity check
// immediately above + the outer accessible_channels gate)
// make this unreachable in normal operation. If a future
// refactor ever lets an empty scope through, fail closed:
// log and skip this filter instead of widening visibility.
tracing::warn!("bridge search rejected empty channel scope: {e}");
continue;
}
}
if let Some(since) = filter.since {
filter_parts.push(format!("created_at:>={}", since.as_secs()));
}
if let Some(until) = filter.until {
filter_parts.push(format!("created_at:<={}", until.as_secs()));
}

let filter_by = filter_parts.join(" && ");

let search_query = buzz_search::SearchQuery {
q: search_text,
filter_by: Some(filter_by),
sort_by: None, // Typesense default = relevance
page: 1,
per_page: limit,
};

let search_result = state
Expand Down
51 changes: 51 additions & 0 deletions crates/buzz-relay/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ pub struct Config {
pub typesense_url: String,
/// Typesense API key.
pub typesense_key: String,
/// Which search backend the relay should use.
pub search_backend: buzz_search::SearchBackend,
/// Public WebSocket URL of this relay, advertised in NIP-11.
pub relay_url: String,
/// Maximum number of concurrent WebSocket connections.
Expand Down Expand Up @@ -151,6 +153,15 @@ impl Config {
let typesense_key =
std::env::var("TYPESENSE_API_KEY").unwrap_or_else(|_| "buzz_dev_key".to_string());

let search_backend = match std::env::var("BUZZ_SEARCH_BACKEND") {
Ok(raw) => buzz_search::SearchBackend::parse(&raw).map_err(|bad| {
ConfigError::InvalidValue(format!(
"BUZZ_SEARCH_BACKEND={bad:?} (expected `typesense`, `postgres`, or `disabled`)"
))
})?,
Err(_) => buzz_search::SearchBackend::Postgres,
};

let relay_url =
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());

Expand Down Expand Up @@ -377,6 +388,7 @@ impl Config {
redis_url,
typesense_url,
typesense_key,
search_backend,
relay_url,
max_connections,
max_concurrent_handlers,
Expand Down Expand Up @@ -548,4 +560,43 @@ mod tests {
Some("custom.example.com")
);
}

#[test]
fn search_backend_defaults_to_postgres() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::remove_var("BUZZ_SEARCH_BACKEND");
let config = Config::from_env().expect("default config");
assert_eq!(config.search_backend, buzz_search::SearchBackend::Postgres);
}

#[test]
fn search_backend_parses_typesense_postgres_and_disabled() {
let _guard = ENV_MUTEX.lock().unwrap();

std::env::set_var("BUZZ_SEARCH_BACKEND", "typesense");
let config = Config::from_env().expect("config");
assert_eq!(config.search_backend, buzz_search::SearchBackend::Typesense);

std::env::set_var("BUZZ_SEARCH_BACKEND", "postgres");
let config = Config::from_env().expect("config");
assert_eq!(config.search_backend, buzz_search::SearchBackend::Postgres);

std::env::set_var("BUZZ_SEARCH_BACKEND", "disabled");
let config = Config::from_env().expect("config");
assert_eq!(config.search_backend, buzz_search::SearchBackend::Disabled);

std::env::remove_var("BUZZ_SEARCH_BACKEND");
}

#[test]
fn search_backend_rejects_unknown_value() {
let _guard = ENV_MUTEX.lock().unwrap();
std::env::set_var("BUZZ_SEARCH_BACKEND", "postgress"); // typo
let result = Config::from_env();
std::env::remove_var("BUZZ_SEARCH_BACKEND");
assert!(
matches!(result, Err(ConfigError::InvalidValue(ref msg)) if msg.contains("BUZZ_SEARCH_BACKEND")),
"expected InvalidValue for BUZZ_SEARCH_BACKEND, got {result:?}",
);
}
}
20 changes: 14 additions & 6 deletions crates/buzz-relay/src/handlers/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1250,13 +1250,21 @@ mod tests {
.await
.expect("pubsub manager"),
);
let audit = buzz_audit::AuditService::new(pool);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(buzz_search::SearchConfig {
url: config.typesense_url.clone(),
api_key: config.typesense_key.clone(),
collection: "events".to_string(),
});
let search = match config.search_backend {
buzz_search::SearchBackend::Typesense => {
buzz_search::SearchService::new(buzz_search::SearchConfig {
url: config.typesense_url.clone(),
api_key: config.typesense_key.clone(),
collection: "events".to_string(),
})
}
buzz_search::SearchBackend::Postgres => {
buzz_search::SearchService::with_postgres(pool.clone())
}
buzz_search::SearchBackend::Disabled => buzz_search::SearchService::disabled(),
};
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
Expand Down
20 changes: 14 additions & 6 deletions crates/buzz-relay/src/handlers/identity_archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,13 +443,21 @@ mod tests {
.await
.ok()?,
);
let audit = buzz_audit::AuditService::new(pool);
let audit = buzz_audit::AuditService::new(pool.clone());
let auth = buzz_auth::AuthService::new(config.auth.clone());
let search = buzz_search::SearchService::new(buzz_search::SearchConfig {
url: config.typesense_url.clone(),
api_key: config.typesense_key.clone(),
collection: "events".to_string(),
});
let search = match config.search_backend {
buzz_search::SearchBackend::Typesense => {
buzz_search::SearchService::new(buzz_search::SearchConfig {
url: config.typesense_url.clone(),
api_key: config.typesense_key.clone(),
collection: "events".to_string(),
})
}
buzz_search::SearchBackend::Postgres => {
buzz_search::SearchService::with_postgres(pool.clone())
}
buzz_search::SearchBackend::Disabled => buzz_search::SearchService::disabled(),
};
let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new(
db.clone(),
buzz_workflow::WorkflowConfig::default(),
Expand Down
Loading
Loading