mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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 <noreply@anthropic.com> Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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<String> = 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<serde_json::Value> = 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.
|
||||
|
||||
Reference in New Issue
Block a user