diff --git a/crates/buzz-relay/src/audio/handler.rs b/crates/buzz-relay/src/audio/handler.rs index b70b307d87..1efb79ccfe 100644 --- a/crates/buzz-relay/src/audio/handler.rs +++ b/crates/buzz-relay/src/audio/handler.rs @@ -15966,6 +15966,37 @@ mod tests { server.abort(); } + /// Binds durable revocation to the real audio AUTH enrollment call. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn durable_ban_revalidates_real_audio_admission_without_delivery() { + let state = audio_test_state_real_db() + .await + .expect("isolated PostgreSQL"); + let (tenant, channel_id, member) = seed_audio_fixture(state.db.pool()).await; + let (mut client, server) = + open_admitted_audio_socket(&state, tenant.clone(), channel_id, &member, None).await; + state + .db + .ban_community_member( + tenant.community(), + member.public_key().as_bytes(), + member.public_key().as_bytes(), + None, + None, + ) + .await + .expect("commit ban only"); + assert_eq!( + state.revalidate_live_authorizations().await, + 1, + "real audio admission must enroll the socket in durable scans" + ); + expect_policy_close(&mut client).await; + server.abort(); + let _ = server.await; + } + /// Every frame up to and including the server's close. async fn audio_frames_until_close( client: &mut tokio_tungstenite::WebSocketStream< diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 2a791e8c33..42ef91281e 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -3140,6 +3140,63 @@ mod tests { } } + /// Binds the durable scan to real AUTH enrollment, with Redis unavailable. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn durable_ban_revalidates_real_root_admission_without_delivery() { + let state = auth_test_state_real_db_expect().await; + let community = seeded_community(&state).await; + let (member, owner, agent, bystander) = ( + Keys::generate(), + Keys::generate(), + Keys::generate(), + Keys::generate(), + ); + let tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("owner proof"); + let tag = serde_json::from_str(&tag).expect("owner tag JSON"); + let mut connections = Vec::new(); + for (keys, tag, challenge) in [ + (&member, None, "durable-member"), + (&agent, Some(tag), "durable-agent"), + (&bystander, None, "durable-bystander"), + ] { + let (conn, _control) = registered_pending_conn(&state, community, challenge); + handle_auth( + signed_auth(keys, challenge, tag), + Arc::clone(&conn), + Arc::clone(&state), + ) + .await; + assert!(matches!( + conn.auth_state_snapshot(), + AuthState::Authenticated(_) + )); + connections.push(conn); + } + for key in [&member, &owner] { + state + .db + .ban_community_member( + community, + key.public_key().as_bytes(), + bystander.public_key().as_bytes(), + None, + None, + ) + .await + .expect("commit ban only"); + } + assert!(connections.iter().all(|conn| !conn.cancel.is_cancelled())); + assert_eq!(state.revalidate_live_authorizations().await, 2); + assert!(connections[0].cancel.is_cancelled(), "direct member closes"); + assert!( + connections[1].cancel.is_cancelled(), + "owner-linked agent closes" + ); + assert!(!connections[2].cancel.is_cancelled(), "bystander stays"); + } + /// AUTH with NIP-OA records a previously ownerless agent's owner. That /// closes the agent's earlier ownerless socket so it reconnects with /// the owner attached, but not the socket being admitted, even when