From 9cd53369c6d4b8657aa6bad23a190882602617dc Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 26 Oct 2026 16:42:29 -0400 Subject: [PATCH] test(relay): first AUTH is not held by a contended roster lock Recording an agent owner republishes the agent's rosters in the background. Cover that a held roster lock does not delay first NIP-OA AUTH, and that the roster shows the agent as `bot` once the lock frees. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Logan Johnson --- crates/buzz-relay/src/handlers/auth.rs | 118 +++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 474edf227a..20d9ccab39 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -2333,6 +2333,124 @@ mod tests { assert!(!watcher.cancel.is_cancelled(), "the bystander stays"); } + /// First AUTH with a NIP-OA credential records the owner, which makes + /// the agent's ordinary rows show `bot`. The rosters must follow, but + /// a contended roster lock must not hold AUTH: the pending AUTH + /// expires after 5 s, the same as the default lock timeout. + /// + /// Mutation: await the roster republish inside + /// `materialize_nip_oa_owner` → AUTH waits on the held lock → RED. + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn first_auth_is_not_held_by_a_contended_roster_lock() { + use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole}; + use std::sync::Arc; + let state = auth_test_state_real_db_expect().await; + let community = seeded_community(&state).await; + let (creator, owner, agent) = (Keys::generate(), Keys::generate(), Keys::generate()); + for key in [&creator, &agent] { + state + .db + .ensure_user(community, &key.public_key().to_bytes()) + .await + .expect("seed user"); + } + let channel = state + .db + .create_channel( + community, + "roster-lock", + ChannelType::Stream, + ChannelVisibility::Open, + None, + &creator.public_key().to_bytes(), + None, + ) + .await + .expect("create channel") + .id; + state + .db + .add_member( + community, + channel, + &agent.public_key().to_bytes(), + MemberRole::Member, + Some(&creator.public_key().to_bytes()), + ) + .await + .expect("add unowned agent"); + + // Hold the roster lock the republish needs. + let relay_pubkey = state.relay_keypair.public_key().to_bytes(); + let held = state + .db + .lock_member_snapshot(community, channel, &relay_pubkey) + .await + .expect("hold roster lock"); + + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "") + .expect("sign NIP-OA credential"); + let auth_tag: Vec = serde_json::from_str(&auth_tag).expect("tag JSON"); + let (conn, _ctrl) = registered_pending_conn(&state, community, "roster-lock"); + tokio::time::timeout( + std::time::Duration::from_secs(2), + handle_auth( + signed_auth(&agent, "roster-lock", Some(auth_tag)), + Arc::clone(&conn), + Arc::clone(&state), + ), + ) + .await + .expect("AUTH must not wait on the roster lock"); + assert!( + matches!(conn.auth_state_snapshot(), AuthState::Authenticated(_)), + "the agent is admitted while the roster lock is held" + ); + assert!(state + .db + .is_agent_owner( + community, + agent.public_key().as_bytes(), + owner.public_key().as_bytes(), + ) + .await + .expect("read owner")); + + // Once the lock is free, the background republish names the agent + // `bot` in the live roster. + held.release().await.expect("release roster lock"); + let agent_hex = agent.public_key().to_hex(); + let deadline = Instant::now() + std::time::Duration::from_secs(10); + loop { + let live: Option = sqlx::query_scalar( + "SELECT tags FROM events WHERE community_id = $1 AND channel_id = $2 \ + AND kind = 39002 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(channel) + .fetch_optional(state.db.pool()) + .await + .expect("read live roster"); + let is_bot = live + .as_ref() + .and_then(|tags| tags.as_array()) + .is_some_and(|tags| { + tags.iter().any(|tag| { + tag[0] == "p" && tag[1] == agent_hex.as_str() && tag[3] == "bot" + }) + }); + if is_bot { + break; + } + assert!( + Instant::now() < deadline, + "the roster must converge to bot after the lock is released: {live:?}" + ); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + } + /// An untagged AUTH that read a NULL stored owner just before a /// concurrent owner link commits must not end up admitted ownerless: /// either the link's disconnect closes it, or it carries the owner.