diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 21f30065f0..19b1c97e9e 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -332,44 +332,13 @@ mod tests { use tower::ServiceExt; async fn test_state() -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); - config.require_relay_membership = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); - config.admin = Some(crate::config::AdminConfig { - host: "admin.example".to_string(), - web_dir: None, - }); - let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); - let db = buzz_db::Db::from_pool(pool.clone()); - let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .expect("redis pool"); - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .expect("pubsub manager"), - ); - let audit = buzz_audit::AuditService::new(pool.clone()); - let auth = buzz_auth::AuthService::new(config.auth.clone()); - let search = buzz_search::SearchService::new(pool.clone()); - let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( - db.clone(), - buzz_workflow::WorkflowConfig::default(), - )); - let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = crate::state::AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - nostr::Keys::generate(), - media_storage, - ); - Arc::new(state) + crate::test_support::test_state_with_config(|config| { + config.admin = Some(crate::config::AdminConfig { + host: "admin.example".to_string(), + web_dir: None, + }); + }) + .await } const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index a2f3640bde..afa0e4d7a6 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -493,6 +493,10 @@ async fn authenticate_media_read( ) -> Result { let tenant = bind_media_read_tenant(state, headers).await?; + if !state.config.require_media_get_auth { + return Ok(MediaReadAuth { tenant }); + } + let auth_event = extract_blossom_auth(headers)?; let sha256 = sha256_ext.split('.').next().unwrap_or(sha256_ext); buzz_media::auth::verify_blossom_get_auth(&auth_event, sha256, Some(tenant.host()), 3600)?; @@ -510,8 +514,12 @@ async fn authenticate_media_read( Ok(MediaReadAuth { tenant }) } -fn blob_cache_control() -> &'static str { - "private, max-age=31536000, immutable" +fn blob_cache_control(require_auth: bool) -> &'static str { + if require_auth { + "private, max-age=31536000, immutable" + } else { + "public, max-age=31536000, immutable" + } } /// Whether a path-segment extension is a safe token. @@ -615,7 +623,7 @@ pub(crate) async fn serve_blob_for_tenant( req_headers: &HeaderMap, ) -> Result { validate_media_path(sha256_ext)?; - let cache_control = blob_cache_control(); + let cache_control = blob_cache_control(state.config.require_media_get_auth); // Sidecar gate FIRST — reject before any blob I/O. Storage is not authoritative. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -793,9 +801,10 @@ pub async fn head_blob( Path(sha256_ext): Path, ) -> Result { validate_media_path(&sha256_ext)?; + let require_media_get_auth = state.config.require_media_get_auth; let media_auth = authenticate_media_read(&state, &headers, &sha256_ext).await?; let tenant = media_auth.tenant; - let cache_control = blob_cache_control(); + let cache_control = blob_cache_control(require_media_get_auth); // Sidecar gate FIRST — reject before any blob I/O. let content_type = if sha256_ext.ends_with(".thumb.jpg") { @@ -937,51 +946,27 @@ mod tests { } async fn test_state() -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); - config.require_relay_membership = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); - config.media_uploads_per_minute = 1; - config.media_max_concurrent_uploads = 2; - config.media_max_concurrent_uploads_per_pubkey = 1; - - let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); - let db = buzz_db::Db::from_pool(pool.clone()); - db.ensure_configured_community("relay.example") - .await - .expect("seed relay.example community for host-bound media tests"); - let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .expect("redis pool"); - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .expect("pubsub manager"), - ); - let audit = buzz_audit::AuditService::new(pool.clone()); - let auth = buzz_auth::AuthService::new(config.auth.clone()); - let search = buzz_search::SearchService::new(pool.clone()); - let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( - db.clone(), - buzz_workflow::WorkflowConfig::default(), - )); - let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - nostr::Keys::generate(), - media_storage, - ); - Arc::new(state) + test_state_with_media_get_auth(false).await } - async fn media_get_auth_router() -> axum::Router { - let state = test_state().await; + async fn test_state_with_media_get_auth(require_media_get_auth: bool) -> Arc { + let state = crate::test_support::test_state_with_config(|config| { + config.require_media_get_auth = require_media_get_auth; + config.media_uploads_per_minute = 1; + config.media_max_concurrent_uploads = 2; + config.media_max_concurrent_uploads_per_pubkey = 1; + }) + .await; + state + .db + .ensure_configured_community("relay.example") + .await + .expect("seed relay.example community for host-bound media tests"); + state + } + + async fn media_get_auth_router(require_media_get_auth: bool) -> axum::Router { + let state = test_state_with_media_get_auth(require_media_get_auth).await; axum::Router::new() .route( "/media/{sha256_ext}", @@ -1027,9 +1012,20 @@ mod tests { } #[tokio::test] - async fn media_reads_reject_unauthenticated_get_and_head_before_sidecar_gate() { + async fn media_get_auth_flag_off_allows_unauthenticated_read_until_sidecar_gate() { + let response = media_get_auth_router(false) + .await + .oneshot(media_request("GET", None)) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn media_get_auth_flag_on_rejects_unauthenticated_get_and_head_before_sidecar_gate() { for method in ["GET", "HEAD"] { - let response = media_get_auth_router() + let response = media_get_auth_router(true) .await .oneshot(media_request(method, None)) .await @@ -1040,10 +1036,10 @@ mod tests { } #[tokio::test] - async fn media_read_with_valid_server_scoped_token_reaches_sidecar_gate() { + async fn media_get_auth_flag_on_valid_server_scoped_token_reaches_sidecar_gate() { let keys = Keys::generate(); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); - let response = media_get_auth_router() + let response = media_get_auth_router(true) .await .oneshot(media_request("GET", Some(auth))) .await @@ -1053,7 +1049,7 @@ mod tests { } #[tokio::test] - async fn media_read_rejects_upload_verb_wrong_server_and_wrong_x() { + async fn media_get_auth_flag_on_rejects_upload_verb_wrong_server_and_wrong_x() { let keys = Keys::generate(); let now = Timestamp::now().as_secs(); let expiration = (now + 300).to_string(); @@ -1077,7 +1073,7 @@ mod tests { for tags in cases { let auth = media_get_auth_header(&keys, tags); - let response = media_get_auth_router() + let response = media_get_auth_router(true) .await .oneshot(media_request("GET", Some(auth))) .await @@ -1094,7 +1090,7 @@ mod tests { } #[tokio::test] - async fn media_read_accepts_range_header_only_after_auth() { + async fn media_get_auth_flag_on_accepts_range_header_only_after_auth() { let keys = Keys::generate(); let auth = media_get_auth_header(&keys, media_get_tags_for("relay.example", None)); let mut request = media_request("GET", Some(auth)); @@ -1102,7 +1098,7 @@ mod tests { .headers_mut() .insert(header::RANGE, "bytes=0-0".parse().expect("range header")); - let response = media_get_auth_router() + let response = media_get_auth_router(true) .await .oneshot(request) .await diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index cf295bdfa3..8f94d3696c 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -2004,44 +2004,14 @@ mod tests { } pub(super) async fn test_state_with_redis_url(redis_url: &str) -> Arc { - let mut config = test_config(); - config.redis_url = redis_url.to_string(); - let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); - let db = buzz_db::Db::from_pool(pool.clone()); - let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .expect("redis pool"); - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .expect("pubsub manager"), - ); - let audit = buzz_audit::AuditService::new(pool.clone()); - let auth = buzz_auth::AuthService::new(config.auth.clone()); - let search = buzz_search::SearchService::new(pool.clone()); - let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( - db.clone(), - buzz_workflow::WorkflowConfig::default(), - )); - let media_storage = - buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - Keys::generate(), - media_storage, - ); - Arc::new(state) + crate::test_support::test_state_with_config(|config| { + config.redis_url = redis_url.to_string(); + }) + .await } pub(super) async fn test_state() -> Arc { - test_state_with_redis_url("redis://127.0.0.1:1").await + crate::test_support::test_state().await } /// Real-PG, real-Redis state that hands back the audit shutdown handle so diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 314adad92e..2d368a571c 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -44,6 +44,8 @@ pub mod subscription; pub mod telemetry; /// Row-zero host binding: resolve the request community from the connection host. pub mod tenant; +#[cfg(test)] +mod test_support; /// Relay-side tunnel session directory and routing. pub mod tunnel; /// Webhook secret generation and constant-time comparison. diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 9fc62f5ac4..0bdcafc80c 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -1411,40 +1411,7 @@ mod tests { } async fn test_state() -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); - config.require_relay_membership = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); - let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); - let db = buzz_db::Db::from_pool(pool.clone()); - let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .expect("redis pool"); - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .expect("pubsub manager"), - ); - let audit = buzz_audit::AuditService::new(pool.clone()); - let auth = buzz_auth::AuthService::new(config.auth.clone()); - let search = buzz_search::SearchService::new(pool.clone()); - let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( - db.clone(), - buzz_workflow::WorkflowConfig::default(), - )); - let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - nostr::Keys::generate(), - media_storage, - ); - Arc::new(state) + crate::test_support::test_state().await } #[test] diff --git a/crates/buzz-relay/src/test_support.rs b/crates/buzz-relay/src/test_support.rs new file mode 100644 index 0000000000..ec8cc650a0 --- /dev/null +++ b/crates/buzz-relay/src/test_support.rs @@ -0,0 +1,95 @@ +//! Shared relay test state for backend-conformance tests. + +use std::sync::Arc; + +use crate::config::Config; +use crate::state::AppState; + +const SQLITE_TEST_URL: &str = "sqlite::memory:"; +const TEST_COMMUNITY_HOST: &str = "relay.example"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TestBackend { + Postgres, + Sqlite, +} + +impl TestBackend { + fn from_env() -> Self { + match std::env::var("BUZZ_TEST_BACKEND") { + Err(_) => Self::Postgres, + Ok(value) if value.eq_ignore_ascii_case("postgres") => Self::Postgres, + Ok(value) if value.eq_ignore_ascii_case("sqlite") => Self::Sqlite, + Ok(value) => panic!("BUZZ_TEST_BACKEND must be 'postgres' or 'sqlite', got {value:?}"), + } + } +} + +/// Build the standard relay test state for `BUZZ_TEST_BACKEND`. +pub(crate) async fn test_state() -> Arc { + test_state_with_config(|_| {}).await +} + +/// Build relay test state after applying test-specific configuration. +pub(crate) async fn test_state_with_config(configure: impl FnOnce(&mut Config)) -> Arc { + let mut config = Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.redis_url = "redis://127.0.0.1:1".to_string(); + configure(&mut config); + + let auxiliary_pg_pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = match TestBackend::from_env() { + TestBackend::Postgres => buzz_db::Db::from_pool(auxiliary_pg_pool.clone()), + TestBackend::Sqlite => { + let db = buzz_db::Db::new_sqlite(SQLITE_TEST_URL) + .await + .expect("in-memory sqlite database"); + db.migrate().await.expect("sqlite migrations"); + db.ensure_configured_community(TEST_COMMUNITY_HOST) + .await + .expect("configured sqlite test community"); + db + } + }; + + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(auxiliary_pg_pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(auxiliary_pg_pool); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + Arc::new(state) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backend_names_are_explicit() { + assert_eq!(TestBackend::Postgres, TestBackend::Postgres); + assert_ne!(TestBackend::Postgres, TestBackend::Sqlite); + } +} diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 97c31c2561..218f90ce97 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -571,42 +571,8 @@ mod integration_tests { use buzz_db::CreateCommunityWithOwnerResult; use std::sync::Arc; - /// Real-PG state mirroring `handlers::event::tests::test_state_with_redis_url`. async fn test_state() -> Arc { - let mut config = crate::config::Config::from_env().expect("default config loads"); - config.require_relay_membership = false; - config.redis_url = "redis://127.0.0.1:1".to_string(); - let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); - let db = buzz_db::Db::from_pool(pool.clone()); - let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .expect("redis pool"); - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .expect("pubsub manager"), - ); - let audit = buzz_audit::AuditService::new(pool.clone()); - let auth = buzz_auth::AuthService::new(config.auth.clone()); - let search = buzz_search::SearchService::new(pool.clone()); - let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( - db.clone(), - buzz_workflow::WorkflowConfig::default(), - )); - let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - nostr::Keys::generate(), - media_storage, - ); - Arc::new(state) + crate::test_support::test_state().await } #[tokio::test]