diff --git a/crates/buzz-relay/SQLITE_HANDLER_TEST_SKIPS.md b/crates/buzz-relay/SQLITE_HANDLER_TEST_SKIPS.md new file mode 100644 index 000000000..3bda596af --- /dev/null +++ b/crates/buzz-relay/SQLITE_HANDLER_TEST_SKIPS.md @@ -0,0 +1,41 @@ +# SQLite relay handler test classification + +S4.2 inventory for relay handler, API, and workflow tests that were originally +`#[ignore = "requires Postgres"]` at harness commit `60c067e`. SQLite runs the +25 implemented-only tests through `BUZZ_TEST_BACKEND=sqlite`; the nine remaining +PostgreSQL tests carry the same greppable `SQLite skip:` reason in their +`#[ignore]` attribute. + +Reasons are copied from [`buzz-db`'s SQLite backend +inventory](../buzz-db/SQLITE_BACKEND_INVENTORY.md). + +## Runs on both backends (25) + +| Test module | Tests converted to shared harness | SQLite methods exercised | +| --- | --- | --- | +| `api::bridge` | four HTTP rejection-counter tests | `ensure_configured_community` | +| `api::git::policy` | `push_gate_denies_owner_through_broken_binding` | `ensure_configured_community`, `insert_event`, read-gate queries | +| `api::git::transport` | four read-gate tests | community/user/channel/member/event lifecycle methods | +| `api::invites` | 11 invite validation/claim/policy/document tests | relay-member and invite methods; side-effect publication is separately skipped below | +| `api::operator` | `non_allowlisted_operator_key_gets_403`, `post_operator_body_requires_payload_tag`, and two malformed transfer-request tests | no community lifecycle mutation; request/auth validation only | +| `handlers::relay_admin` | two kind-9033 admission tests | `ensure_configured_community`, relay-member and icon methods | + +## SQLite skips (9) + +| Test | S2 unsupported method | Inventory reason | +| --- | --- | --- | +| `api::invites::bounded_v2_claims_publish_side_effects_only_for_joined` | `publish_nip43_membership_locked` | relay membership maintenance is PostgreSQL-only | +| `api::operator::unmapped_management_host_can_check_availability` | `lookup_community_by_host_for_management` | community lifecycle management is PostgreSQL-only | +| `api::operator::unmapped_management_host_can_list_owned_communities` | `list_communities_owned_by` | community lifecycle management is PostgreSQL-only | +| `api::operator::unarchive_restores_admission_and_is_idempotent_without_changing_ownership` | `unarchive_community_owned_by` | community lifecycle management is PostgreSQL-only | +| `api::operator::archive_publish_failure_is_retryable_and_preserves_timestamp` | `archive_community_owned_by` | community lifecycle management is PostgreSQL-only | +| `api::operator::happy_path_create_returns_created_and_bootstraps_owner` | `create_community_with_owner` | community lifecycle management is PostgreSQL-only | +| `api::operator::fresh_host_at_owner_limit_returns_limit_reached_conflict` | `create_community_with_owner` | community lifecycle management is PostgreSQL-only | +| `api::operator::happy_path_transfer_swaps_owner_and_demotes_old_to_member` | `transfer_ownership` | relay membership maintenance is PostgreSQL-only | +| `workflow_sink::integration_tests::workflow_send_message_p_tags_mentioned_member` | `create_community_with_owner` | community lifecycle management is PostgreSQL-only | + +## Known PostgreSQL-side failures (not changed here) + +The existing PostgreSQL integration run has three known failures: the +`product_feedback`, `relay_members`, and owner-limit suites. They reproduce at +the S4.1 base and are not SQLite-harness regressions. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 4981845b1..34448e0ce 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3363,61 +3363,19 @@ mod tests { } } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - /// Build an AppState suitable for handler-level bridge tests. - /// - /// - `require_auth_token = false` → X-Pubkey dev-mode fallback active. - /// - `require_relay_membership = false` → membership check short-circuits to - /// OpenRelay without a DB lookup. - /// - `nip98_replay` replaced with an always-fresh guard → no Redis needed - /// for replay detection. - /// - Redis pool points at the local dev instance for the admission check. - /// - /// Returns `None` when local Postgres is not reachable. - async fn bridge_handler_test_state() -> Option> { - let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); - // Use the real local Redis so enforce_http_admission can pass. - config.redis_url = - std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); - config.relay_url = "wss://bridge-test.local".to_string(); - config.require_auth_token = false; - config.require_relay_membership = false; - - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; - 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)) - .ok()?; - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .ok()?, - ); - 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).ok()?; - - let (mut state, _audit_shutdown) = crate::state::AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - Keys::generate(), - media_storage, - ); - state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); - Some(Arc::new(state)) + async fn bridge_handler_test_state() -> Arc { + crate::test_support::test_state_with_config_and_state( + |config| { + config.redis_url = std::env::var("REDIS_URL") + .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); + config.relay_url = "wss://bridge-test.local".to_string(); + config.require_auth_token = false; + config.require_relay_membership = false; + }, + |state| state.nip98_replay = Arc::new(AlwaysFreshReplayGuard), + ) + .await } /// Drive a single POST /events request through the router and return the @@ -3483,16 +3441,13 @@ mod tests { /// Discriminating: if the `reject_with_transport` call in bridge.rs's /// `serde_json::from_slice` map_err closure is removed, this test fails. #[test] - #[ignore = "requires Postgres"] fn submit_event_invalid_json_body_increments_http_transport_counter() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("current_thread runtime"); - let Some(state) = rt.block_on(bridge_handler_test_state()) else { - panic!("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); - }; + let state = rt.block_on(bridge_handler_test_state()); // Provision a fresh community so bind_community succeeds. let host = { @@ -3539,16 +3494,13 @@ mod tests { /// Discriminating: if the `reject_with_transport` call in bridge.rs's /// IngestError::Rejected match arm is removed, this test fails. #[test] - #[ignore = "requires Postgres"] fn submit_event_relay_only_kind_increments_http_transport_counter() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("current_thread runtime"); - let Some(state) = rt.block_on(bridge_handler_test_state()) else { - panic!("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); - }; + let state = rt.block_on(bridge_handler_test_state()); let host = { let h = format!("bridge-test-{}.local", uuid::Uuid::new_v4().simple()); @@ -3671,16 +3623,13 @@ mod tests { /// Discriminating: if the attribution log is removed from the ParseFail /// arm in submit_event, this test fails. #[test] - #[ignore = "requires Postgres"] fn submit_event_invalid_json_emits_exactly_one_attribution_line() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("current_thread runtime"); - let state = rt - .block_on(bridge_handler_test_state()) - .expect("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); + let state = rt.block_on(bridge_handler_test_state()); let host = { let h = format!("bridge-attr-{}.local", uuid::Uuid::new_v4().simple()); @@ -3722,16 +3671,13 @@ mod tests { /// Discriminating: if two log lines are emitted (old double-log bug), this /// test fails. #[test] - #[ignore = "requires Postgres"] fn submit_event_relay_only_kind_emits_exactly_one_attribution_line() { let rt = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("current_thread runtime"); - let state = rt - .block_on(bridge_handler_test_state()) - .expect("local Postgres not reachable — start Postgres on 127.0.0.1:5432 before running ignored bridge handler tests"); + let state = rt.block_on(bridge_handler_test_state()); let host = { let h = format!("bridge-attr-{}.local", uuid::Uuid::new_v4().simple()); diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index 32d63f460..e81cb4e4a 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -822,48 +822,8 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/nu // ── hook_policy_check binding gate (requires Postgres) ────────────── - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn policy_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.database_url = std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_string()); - let pool = sqlx::PgPool::connect(&config.database_url) - .await - .expect("connect test DB"); - 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 } /// Announce `repo_id` with the given tags, then push to it as its own @@ -927,7 +887,6 @@ printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/nu /// the owner a push path through a binding the read gate refuses to /// honor. The remediation token stays reserved for genuinely NotBound. #[tokio::test] - #[ignore = "requires Postgres"] async fn push_gate_denies_owner_through_broken_binding() { use nostr::{Keys, Tag}; diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 53e3f5946..250d85592 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2799,14 +2799,8 @@ mod sec005_read_gate_tests { // ── authorize_git_read matrix (requires Postgres) ──────────────────── - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_db() -> buzz_db::Db { - let url = std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_string()); - let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); - buzz_db::Db::from_pool(pool) + crate::test_support::test_state().await.db.clone() } /// How the fixture's kind:30617 binds (or fails to bind) a channel. @@ -2909,7 +2903,6 @@ mod sec005_read_gate_tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn read_gate_allows_current_member_denies_removed_and_owner_bypass() { let f = setup_repo(Binding::Channel).await; @@ -2955,7 +2948,6 @@ mod sec005_read_gate_tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn read_gate_denies_missing_or_malformed_binding_and_absent_repo() { // Missing buzz-channel tag → deny even for a channel member, with // the generic body: the remediation carve-out is author-only. @@ -3035,7 +3027,6 @@ mod sec005_read_gate_tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn read_gate_gives_author_of_unbound_repo_remediation_body() { // Issue #3527: the author of a never-bound announcement is the one // identity that can fix it (30617 is keyed by (author, d)) and the @@ -3082,7 +3073,6 @@ mod sec005_read_gate_tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn read_gate_follows_current_announcement_not_stale_registry() { // Max's registry/pointer concern: a soft-deleted 30617 can leave the // `git_repo_names` reservation and the manifest pointer alive. Reads diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 6104171cc..1b7a20e91 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -565,8 +565,6 @@ mod tests { } } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - fn claim_cache( capacity: u64, ttl: Duration, @@ -648,53 +646,23 @@ mod tests { } /// Build a closed-relay (`require_relay_membership = true`) test state with - /// a fresh community on `host`; returns `None` when Postgres is unavailable. - async fn invite_test_state(host: &str) -> Option> { - let mut config = crate::config::Config::from_env().ok()?; - let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_string()); - config.database_url = database_url.clone(); - config.redis_url = "redis://127.0.0.1:1".to_string(); - config.relay_url = format!("wss://{host}"); - // The claim route must work on relays where membership is enforced — - // that is the entire point of an invite. - config.require_relay_membership = true; - - let pool = sqlx::PgPool::connect(&database_url).await.ok()?; - let db = buzz_db::Db::from_pool(pool.clone()); - db.ensure_configured_community(host).await.ok()?; - - let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .ok()?; - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .ok()?, - ); - 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).ok()?; - let (mut state, _audit_shutdown) = AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - Keys::generate(), - media_storage, - ); - state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); - Some(Arc::new(state)) + /// a fresh community on `host`. + async fn invite_test_state(host: &str) -> Arc { + let state = crate::test_support::test_state_with_config_and_state( + |config| { + config.relay_url = format!("wss://{host}"); + // The claim route must work on relays where membership is enforced. + config.require_relay_membership = true; + }, + |state| state.nip98_replay = Arc::new(AlwaysFreshReplayGuard), + ) + .await; + state + .db + .ensure_configured_community(host) + .await + .expect("ensure community"); + state } async fn post_json( @@ -838,13 +806,10 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn mint_validates_max_uses_and_ttl_bounds() { let host = format!("invites-validation-{}.example", Uuid::new_v4().simple()); let owner = Keys::generate(); - let state = invite_test_state(&host) - .await - .expect("requires reachable Postgres and relay test state"); + let state = invite_test_state(&host).await; let community = state .db .lookup_community_by_host(&host) @@ -896,13 +861,10 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn malformed_and_unknown_v2_codes_are_forbidden_without_v1_fallback() { let host = format!("invites-v2-invalid-{}.example", Uuid::new_v4().simple()); let joiner = Keys::generate(); - let state = invite_test_state(&host) - .await - .expect("requires reachable Postgres and relay test state"); + let state = invite_test_state(&host).await; let unknown = format!("v2.{}", URL_SAFE_NO_PAD.encode([9_u8; 32])); for code in [ @@ -931,7 +893,7 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: publish_nip43_membership_locked — relay membership maintenance is PostgreSQL-only"] async fn bounded_v2_claims_publish_side_effects_only_for_joined() { let host = format!( "invites-v2-side-effects-{}.example", @@ -940,9 +902,7 @@ mod tests { let owner = Keys::generate(); let first = Keys::generate(); let second = Keys::generate(); - let state = invite_test_state(&host) - .await - .expect("requires reachable Postgres and relay test state"); + let state = invite_test_state(&host).await; let community = state .db .lookup_community_by_host(&host) @@ -1078,14 +1038,11 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn owner_mints_and_new_pubkey_claims() { let host = format!("invites-{}.example", Uuid::new_v4().simple()); let owner = Keys::generate(); let joiner = Keys::generate(); - let Some(state) = invite_test_state(&host).await else { - return; - }; + let state = invite_test_state(&host).await; let community = state .db .lookup_community_by_host(&host) @@ -1148,14 +1105,11 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn join_policy_gate_end_to_end() { let host = format!("invites-policy-{}.example", Uuid::new_v4().simple()); let owner = Keys::generate(); let joiner = Keys::generate(); - let Some(state) = invite_test_state(&host).await else { - return; - }; + let state = invite_test_state(&host).await; // Force the join policy on regardless of env. let mut state_inner = (*state).clone(); let mut config = state_inner.config.as_ref().clone(); @@ -1357,14 +1311,11 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn non_admin_cannot_mint() { let host = format!("invites-{}.example", Uuid::new_v4().simple()); let member = Keys::generate(); let outsider = Keys::generate(); - let Some(state) = invite_test_state(&host).await else { - return; - }; + let state = invite_test_state(&host).await; let community = state .db .lookup_community_by_host(&host) @@ -1386,13 +1337,10 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn claim_rejects_invalid_code() { let host = format!("invites-{}.example", Uuid::new_v4().simple()); let joiner = Keys::generate(); - let Some(state) = invite_test_state(&host).await else { - return; - }; + let state = invite_test_state(&host).await; let body = serde_json::json!({ "code": "garbage.code" }).to_string(); let response = post_json(state.clone(), &host, "/api/invites/claim", &joiner, body).await; @@ -1418,15 +1366,12 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn code_minted_for_one_community_fails_on_another() { let host_a = format!("invites-a-{}.example", Uuid::new_v4().simple()); let host_b = format!("invites-b-{}.example", Uuid::new_v4().simple()); let owner = Keys::generate(); let joiner = Keys::generate(); - let Some(state) = invite_test_state(&host_a).await else { - return; - }; + let state = invite_test_state(&host_a).await; state .db .ensure_configured_community(&host_b) @@ -1498,13 +1443,10 @@ mod tests { /// rejected by `/api/invites/claim` with the distinguishable /// `invite_expired` body, and do not admit the caller. #[tokio::test] - #[ignore = "requires Postgres"] async fn claim_rejects_expired_code() { let host = format!("invites-{}.example", Uuid::new_v4().simple()); let joiner = Keys::generate(); - let state = invite_test_state(&host) - .await - .expect("requires reachable Postgres and relay test state"); + let state = invite_test_state(&host).await; let community = state .db .lookup_community_by_host(&host) @@ -1569,14 +1511,11 @@ mod tests { /// Authorization header (same signed NIP-98 event id) is rejected as /// replay before the invite verification ever runs. #[tokio::test] - #[ignore = "requires Postgres"] async fn claim_rejects_replayed_nip98_auth() { let host = format!("invites-{}.example", Uuid::new_v4().simple()); let owner = Keys::generate(); let joiner = Keys::generate(); - let state_arc = invite_test_state(&host) - .await - .expect("requires reachable Postgres and relay test state"); + let state_arc = invite_test_state(&host).await; // Swap the always-fresh guard for one that fires the second time the // same event id is presented — the code path we're pinning. let mut state_owned = @@ -1656,13 +1595,10 @@ mod tests { /// `invite_invalid` (403) to `too many invite claim attempts` (429) proves /// the limiter guard is on the request path and fires on repeat pubkey. #[tokio::test] - #[ignore = "requires Postgres"] async fn claim_rate_limit_fires_on_repeat_pubkey() { let host = format!("invites-{}.example", Uuid::new_v4().simple()); let joiner = Keys::generate(); - let state_arc = invite_test_state(&host) - .await - .expect("requires reachable Postgres and relay test state"); + let state_arc = invite_test_state(&host).await; // Fresh limiter with the production limit so the assertion pins the // in-endpoint threshold, not a test-only budget. let mut state_owned = @@ -1725,12 +1661,9 @@ mod tests { /// The document routes are public (no NIP-98) and 404 until configured, /// exactly like the JSON policy endpoint they sit beside. #[tokio::test] - #[ignore = "requires Postgres"] async fn join_policy_document_pages_serve_configured_markdown() { let host = format!("invites-docs-{}.example", Uuid::new_v4().simple()); - let Some(state) = invite_test_state(&host).await else { - return; - }; + let state = invite_test_state(&host).await; let get_page = |state: Arc, path: &'static str| { let host = host.clone(); diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index 5b69a4387..4b28303d5 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -533,7 +533,6 @@ mod tests { } } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 const INGRESS_HOST: &str = "operator-ingress.example"; fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { @@ -569,51 +568,20 @@ mod tests { format!("Nostr {encoded}") } - async fn operator_test_state(operator_keys: &[Keys]) -> Option> { - let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); - config.redis_url = "redis://127.0.0.1:1".to_string(); - config.relay_url = "wss://tenant.example".to_string(); - config.relay_operator_api_origin = Some(format!("http://{INGRESS_HOST}")); - config.relay_operator_pubkeys = operator_keys - .iter() - .map(|keys| keys.public_key().to_hex()) - .collect(); - config.require_relay_membership = true; - - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; - 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)) - .ok()?; - let pubsub = Arc::new( - buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) - .await - .ok()?, - ); - 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).ok()?; - let (mut state, _audit_shutdown) = AppState::new( - config, - db, - redis_pool, - audit, - pubsub, - auth, - search, - workflow_engine, - Keys::generate(), - media_storage, - ); - state.nip98_replay = Arc::new(AlwaysFreshReplayGuard); - Some(Arc::new(state)) + async fn operator_test_state(operator_keys: &[Keys]) -> Arc { + crate::test_support::test_state_with_config_and_state( + |config| { + config.relay_url = "wss://tenant.example".to_string(); + config.relay_operator_api_origin = Some(format!("http://{INGRESS_HOST}")); + config.relay_operator_pubkeys = operator_keys + .iter() + .map(|keys| keys.public_key().to_hex()) + .collect(); + config.require_relay_membership = true; + }, + |state| state.nip98_replay = Arc::new(AlwaysFreshReplayGuard), + ) + .await } async fn read_json(response: axum::response::Response) -> Value { @@ -703,13 +671,10 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn non_allowlisted_operator_key_gets_403() { let operator = Keys::generate(); let outsider = Keys::generate(); - let Some(state) = operator_test_state(&[operator]).await else { - return; - }; + let state = operator_test_state(&[operator]).await; let body = format!( r#"{{"host":"community-{}.example"}}"#, Uuid::new_v4().simple() @@ -735,12 +700,9 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] async fn post_operator_body_requires_payload_tag() { let operator = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let body = format!( r#"{{"host":"community-{}.example"}}"#, Uuid::new_v4().simple() @@ -774,12 +736,10 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: lookup_community_by_host_for_management — community lifecycle management is PostgreSQL-only"] async fn unmapped_management_host_can_check_availability() { let operator = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); let query = format!("host={host}"); let url = format!("http://{INGRESS_HOST}/operator/communities/availability?{query}"); @@ -803,13 +763,11 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: list_communities_owned_by — community lifecycle management is PostgreSQL-only"] async fn unmapped_management_host_can_list_owned_communities() { let operator = Keys::generate(); let owner = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let owner_hex = owner.public_key().to_hex(); let query = format!("owner_pubkey={owner_hex}"); let url = format!("http://{INGRESS_HOST}/operator/communities?{query}"); @@ -836,14 +794,12 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: unarchive_community_owned_by — community lifecycle management is PostgreSQL-only"] async fn unarchive_restores_admission_and_is_idempotent_without_changing_ownership() { let operator = Keys::generate(); let owner = Keys::generate(); let outsider = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); assert_eq!( provision_community(Arc::clone(&state), &operator, &host, &owner) @@ -927,13 +883,11 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: archive_community_owned_by — community lifecycle management is PostgreSQL-only"] async fn archive_publish_failure_is_retryable_and_preserves_timestamp() { let operator = Keys::generate(); let owner = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); let owner_hex = owner.public_key().to_hex(); let create_body = serde_json::json!({ @@ -1039,13 +993,11 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: create_community_with_owner — community lifecycle management is PostgreSQL-only"] async fn happy_path_create_returns_created_and_bootstraps_owner() { let operator = Keys::generate(); let owner = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); let response = provision_community(state.clone(), &operator, &host, &owner).await; @@ -1075,13 +1027,11 @@ mod tests { } #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: create_community_with_owner — community lifecycle management is PostgreSQL-only"] async fn fresh_host_at_owner_limit_returns_limit_reached_conflict() { let operator = Keys::generate(); let owner = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; for _ in 0..buzz_db::relay_members::MAX_COMMUNITIES_PER_OWNER { let host = format!("community-{}.example", Uuid::new_v4().simple()); @@ -1112,14 +1062,12 @@ mod tests { /// the old owner to `member`, and publishes a NIP-43 snapshot reflecting the /// new roles. #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: transfer_ownership — relay membership maintenance is PostgreSQL-only"] async fn happy_path_transfer_swaps_owner_and_demotes_old_to_member() { let operator = Keys::generate(); let initial_owner = Keys::generate(); let new_owner = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let host = format!("community-{}.example", Uuid::new_v4().simple()); let create_response = @@ -1199,13 +1147,10 @@ mod tests { /// Transfer with an invalid community_id returns 400. #[tokio::test] - #[ignore = "requires Postgres"] async fn transfer_with_invalid_community_id_returns_400() { let operator = Keys::generate(); let new_owner = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let body = serde_json::json!({ "community_id": "not-a-uuid", "new_owner_pubkey": new_owner.public_key().to_hex(), @@ -1226,12 +1171,9 @@ mod tests { /// Transfer with an invalid new_owner_pubkey returns 400. #[tokio::test] - #[ignore = "requires Postgres"] async fn transfer_with_invalid_pubkey_returns_400() { let operator = Keys::generate(); - let Some(state) = operator_test_state(std::slice::from_ref(&operator)).await else { - return; - }; + let state = operator_test_state(std::slice::from_ref(&operator)).await; let body = serde_json::json!({ "community_id": Uuid::new_v4().to_string(), "new_owner_pubkey": "not-a-pubkey", diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c51..29215b227 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -696,63 +696,24 @@ mod tests { // explicitly in CI's Backend Integration job; requires local Postgres // (and hard-fails rather than skipping when it is unreachable). - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - /// Build a real `AppState` + tenant for a fresh community on `host`, with - /// `require_relay_membership` set as given. Mirrors - /// `api::invites::tests::invite_test_state`. + /// `require_relay_membership` set as given. async fn workspace_profile_test_state( host: &str, require_relay_membership: bool, ) -> (Arc, TenantContext) { - let mut config = crate::config::Config::from_env().expect("config from env"); - let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_string()); - config.database_url = database_url.clone(); - config.redis_url = "redis://127.0.0.1:1".to_string(); - config.relay_url = format!("wss://{host}"); - config.require_relay_membership = require_relay_membership; - - let pool = sqlx::PgPool::connect(&database_url) - .await - .expect("requires reachable Postgres"); - let db = buzz_db::Db::from_pool(pool.clone()); - let record = db + let state = crate::test_support::test_state_with_config(|config| { + config.relay_url = format!("wss://{host}"); + config.require_relay_membership = require_relay_membership; + }) + .await; + let record = state + .db .ensure_configured_community(host) .await .expect("ensure community"); let tenant = TenantContext::resolved(record.id, host); - - let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) - .create_pool(Some(deadpool_redis::Runtime::Tokio1)) - .expect("redis pool config"); - 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), tenant) + (state, tenant) } /// Sign a fresh kind:9033 with `icon` and run it through the real @@ -785,7 +746,6 @@ mod tests { /// Discriminating: fails if the call site inverts or drops /// `require_relay_membership`, or stops consulting `has_admin_or_owner`. #[tokio::test] - #[ignore = "requires Postgres"] async fn open_relay_9033_admits_roleless_only_until_a_steward_exists() { let host = format!("icon-gate-open-{}.example", uuid::Uuid::new_v4().simple()); let (state, tenant) = workspace_profile_test_state(&host, false).await; @@ -844,7 +804,6 @@ mod tests { /// branch. Together with the open-relay test this kills the inverted-flag /// mutant: no assignment of the flag satisfies both. #[tokio::test] - #[ignore = "requires Postgres"] async fn closed_relay_9033_still_requires_admin_or_owner() { let host = format!("icon-gate-closed-{}.example", uuid::Uuid::new_v4().simple()); let (state, tenant) = workspace_profile_test_state(&host, true).await; diff --git a/crates/buzz-relay/src/test_support.rs b/crates/buzz-relay/src/test_support.rs index ec8cc650a..8799d2122 100644 --- a/crates/buzz-relay/src/test_support.rs +++ b/crates/buzz-relay/src/test_support.rs @@ -32,6 +32,17 @@ pub(crate) async fn test_state() -> Arc { /// Build relay test state after applying test-specific configuration. pub(crate) async fn test_state_with_config(configure: impl FnOnce(&mut Config)) -> Arc { + test_state_with_config_and_state(configure, |_| {}).await +} + +/// Build relay test state with test-specific configuration and state overrides. +/// +/// The latter is intentionally limited to test-only seams such as the NIP-98 +/// replay guard; database selection remains owned by this shared harness. +pub(crate) async fn test_state_with_config_and_state( + configure: impl FnOnce(&mut Config), + configure_state: impl FnOnce(&mut AppState), +) -> 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(); @@ -68,7 +79,7 @@ pub(crate) async fn test_state_with_config(configure: impl FnOnce(&mut Config)) buzz_workflow::WorkflowConfig::default(), )); let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); - let (state, _audit_shutdown) = AppState::new( + let (mut state, _audit_shutdown) = AppState::new( config, db, redis_pool, @@ -80,6 +91,7 @@ pub(crate) async fn test_state_with_config(configure: impl FnOnce(&mut Config)) nostr::Keys::generate(), media_storage, ); + configure_state(&mut state); Arc::new(state) } diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 218f90ce9..19688e4aa 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -576,7 +576,7 @@ mod integration_tests { } #[tokio::test] - #[ignore = "requires Postgres"] + #[ignore = "requires Postgres; SQLite skip: create_community_with_owner — community lifecycle management is PostgreSQL-only"] async fn workflow_send_message_p_tags_mentioned_member() { let state = test_state().await;