mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(relay): allow agent owners to edit/manage agent-owned content (#1403)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
06ef533ec7
commit
0042c8e106
Generated
+1
@@ -1109,6 +1109,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"buzz-core",
|
||||
"buzz-sdk",
|
||||
"buzz-ws-client",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
|
||||
@@ -676,7 +676,8 @@ pub(crate) fn effective_message_author(event: &Event, relay_pubkey: &nostr::Publ
|
||||
event.pubkey.to_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Validate kind:40003 edit ownership — event.pubkey must match target's effective author.
|
||||
/// Validate kind:40003 edit ownership — event.pubkey must match target's effective author,
|
||||
/// or the actor must be the owning human of the agent that authored the target message.
|
||||
async fn validate_edit_ownership(
|
||||
community_id: CommunityId,
|
||||
event: &Event,
|
||||
@@ -723,8 +724,36 @@ async fn validate_edit_ownership(
|
||||
|
||||
let author = effective_message_author(&target_event.event, &state.relay_keypair.public_key());
|
||||
let actor = event.pubkey.to_bytes().to_vec();
|
||||
if author != actor {
|
||||
return Err("must be event author to edit".to_string());
|
||||
if author == actor {
|
||||
// Author editing their own message: re-gate on membership/open visibility so that
|
||||
// a removed private-channel member cannot mutate old messages after access is revoked.
|
||||
if let Some(ch_id) = target_event.channel_id {
|
||||
let is_member = state
|
||||
.is_member_cached(community_id, ch_id, &actor)
|
||||
.await
|
||||
.map_err(|e| format!("db error checking membership: {e}"))?;
|
||||
if !is_member {
|
||||
let is_open = state
|
||||
.db
|
||||
.get_channel(community_id, ch_id)
|
||||
.await
|
||||
.map(|ch| ch.visibility == "open")
|
||||
.unwrap_or(false);
|
||||
if !is_open {
|
||||
return Err("restricted: not a channel member".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Allow the owning human to edit messages authored by their agent.
|
||||
let is_owner = state
|
||||
.db
|
||||
.is_agent_owner(community_id, &author, &actor)
|
||||
.await
|
||||
.map_err(|e| format!("db error checking agent ownership: {e}"))?;
|
||||
if !is_owner {
|
||||
return Err("must be event author to edit".to_string());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1379,8 +1408,17 @@ async fn ingest_event_inner(
|
||||
if let Some(ch_id) = channel_id {
|
||||
// kind:9021 (join) doesn't require prior membership.
|
||||
// kind:9007 (create) — channel doesn't exist yet; creator becomes owner in step 16.
|
||||
let skip_membership =
|
||||
kind_u32 == KIND_NIP29_JOIN_REQUEST || kind_u32 == KIND_NIP29_CREATE_GROUP;
|
||||
// kind:40003/9002/9005/9008 — per-kind validators are the authority; they
|
||||
// individually enforce authorization and fail closed. Bypassing the generic
|
||||
// member/open gate here lets the owning human act on private agent channels
|
||||
// without being a member (OQ1 decision; see validate_edit_ownership /
|
||||
// validate_admin_event for per-kind enforcement).
|
||||
let skip_membership = kind_u32 == KIND_NIP29_JOIN_REQUEST
|
||||
|| kind_u32 == KIND_NIP29_CREATE_GROUP
|
||||
|| kind_u32 == KIND_STREAM_MESSAGE_EDIT
|
||||
|| kind_u32 == KIND_NIP29_EDIT_METADATA
|
||||
|| kind_u32 == KIND_NIP29_DELETE_EVENT
|
||||
|| kind_u32 == KIND_NIP29_DELETE_GROUP;
|
||||
if !skip_membership {
|
||||
// Spec AuthCheck (line 794): emit the verdict at the actual
|
||||
// call site. claimed_community comes from the event's h tag
|
||||
|
||||
@@ -216,6 +216,30 @@ pub async fn validate_standard_deletion_event(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns `true` if `actor_bytes` is the NIP-OA owner of **any** active owner-role
|
||||
/// member in `members`. Used by kind:9002 and kind:9008 to authorize the owning
|
||||
/// human of a channel's agent-owner(s) even when the human is not a channel member.
|
||||
///
|
||||
/// Checking all active owners (not just the first) is correct under co-ownership:
|
||||
/// an agent may be promoted to owner later, or multiple agents may co-own a channel.
|
||||
async fn actor_owns_any_owner_agent(
|
||||
state: &Arc<AppState>,
|
||||
community_id: buzz_core::CommunityId,
|
||||
members: &[buzz_db::channel::MemberRecord],
|
||||
actor_bytes: &[u8],
|
||||
) -> anyhow::Result<bool> {
|
||||
for owner in members.iter().filter(|m| m.role == "owner") {
|
||||
if state
|
||||
.db
|
||||
.is_agent_owner(community_id, &owner.pubkey, actor_bytes)
|
||||
.await?
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Validate an admin kind event BEFORE storage.
|
||||
pub async fn validate_admin_event(
|
||||
tenant: &TenantContext,
|
||||
@@ -459,9 +483,24 @@ pub async fn validate_admin_event(
|
||||
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
|
||||
match actor_member {
|
||||
Some(m) if m.role == "owner" || m.role == "admin" => Ok(()),
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"actor not authorized for name/about/archived/visibility/ttl changes"
|
||||
)),
|
||||
_ => {
|
||||
// Allow the owning human of any active owner-role agent in the
|
||||
// channel, even when the human is not a channel member —
|
||||
// diverges from kind:9001 intentionally.
|
||||
if actor_owns_any_owner_agent(
|
||||
state,
|
||||
tenant.community(),
|
||||
&members,
|
||||
&actor_bytes,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"actor not authorized for name/about/archived/visibility/ttl changes"
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// topic/purpose: any member
|
||||
@@ -516,26 +555,66 @@ pub async fn validate_admin_event(
|
||||
let author =
|
||||
effective_message_author(&target_event.event, &state.relay_keypair.public_key());
|
||||
if author == actor_bytes {
|
||||
return Ok(()); // Author can always delete their own messages
|
||||
// Author deleting their own message: re-gate on membership/open visibility so that
|
||||
// a removed private-channel member cannot mutate old messages after access is revoked.
|
||||
let is_member = state
|
||||
.is_member_cached(tenant.community(), channel_id, &actor_bytes)
|
||||
.await?;
|
||||
if is_member {
|
||||
return Ok(());
|
||||
}
|
||||
let is_open = state
|
||||
.db
|
||||
.get_channel(tenant.community(), channel_id)
|
||||
.await
|
||||
.map(|ch| ch.visibility == "open")
|
||||
.unwrap_or(false);
|
||||
if is_open {
|
||||
return Ok(());
|
||||
}
|
||||
// Not a member and channel is private — fall through to owner/admin/owner-of-agent check.
|
||||
}
|
||||
|
||||
// Not the author — must be owner/admin.
|
||||
// Not the author, or author who is no longer a member of a private channel —
|
||||
// must be owner/admin or the owning human of the message's agent-author.
|
||||
let members = state.db.get_members(tenant.community(), channel_id).await?;
|
||||
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
|
||||
match actor_member {
|
||||
Some(m) if m.role == "owner" || m.role == "admin" => Ok(()),
|
||||
_ => Err(anyhow::anyhow!(
|
||||
"must be event author or channel owner/admin"
|
||||
)),
|
||||
_ => {
|
||||
// Allow the owning human of the agent that authored the target message,
|
||||
// even when the human is not a channel member.
|
||||
if state
|
||||
.db
|
||||
.is_agent_owner(tenant.community(), &author, &actor_bytes)
|
||||
.await?
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(
|
||||
"must be event author or channel owner/admin"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9008 => {
|
||||
// DELETE_GROUP: owner only
|
||||
// DELETE_GROUP: owner only, or the owning human of the channel's agent-owner.
|
||||
let members = state.db.get_members(tenant.community(), channel_id).await?;
|
||||
let actor_member = members.iter().find(|m| m.pubkey == actor_bytes);
|
||||
match actor_member {
|
||||
Some(m) if m.role == "owner" => Ok(()),
|
||||
_ => Err(anyhow::anyhow!("only owner can delete group")),
|
||||
_ => {
|
||||
// Allow the owning human of any active owner-role agent in the
|
||||
// channel, even when the human is not a channel member —
|
||||
// diverges from kind:9001 intentionally.
|
||||
if actor_owns_any_owner_agent(state, tenant.community(), &members, &actor_bytes)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(anyhow::anyhow!("only owner can delete group"))
|
||||
}
|
||||
}
|
||||
}
|
||||
9022 => {
|
||||
|
||||
@@ -35,6 +35,7 @@ rand = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
s3 = { version = "0.37", package = "rust-s3", default-features = false, features = ["tokio-rustls-tls", "fail-on-err", "tags"] }
|
||||
buzz-sdk = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "buzz-test-cli"
|
||||
|
||||
@@ -106,6 +106,18 @@ impl BuzzTestClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Performs NIP-42 authentication with a NIP-OA `auth` tag, establishing
|
||||
/// the agent→owner relationship in the relay's DB. Call this as the
|
||||
/// *agent* connection; the tag must be signed by `owner_keys`.
|
||||
pub async fn authenticate_with_nip_oa(
|
||||
&mut self,
|
||||
agent_keys: &Keys,
|
||||
auth_tag: &Tag,
|
||||
) -> Result<(), TestClientError> {
|
||||
self.inner.authenticate(agent_keys, Some(auth_tag)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends a signed event to the relay and waits for the OK response.
|
||||
pub async fn send_event(&mut self, event: Event) -> Result<OkResponse, TestClientError> {
|
||||
Ok(self.inner.send_event(event).await?)
|
||||
|
||||
@@ -0,0 +1,899 @@
|
||||
//! End-to-end tests for human owners editing/managing content authored by
|
||||
//! their agents — all four authorization predicate sites:
|
||||
//!
|
||||
//! - kind:40003 message edit (`validate_edit_ownership`)
|
||||
//! - kind:9005 DELETE_EVENT (`validate_admin_event` 9005 branch)
|
||||
//! - kind:9002 EDIT_METADATA privileged-tag branch
|
||||
//! - kind:9008 DELETE_GROUP
|
||||
//!
|
||||
//! The owner→agent relationship is established via NIP-OA: the agent
|
||||
//! connects and authenticates with an `auth` tag signed by the owner.
|
||||
//!
|
||||
//! # Running
|
||||
//!
|
||||
//! Start the relay, then run:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo test --test e2e_human_edit_agent_content -- --ignored
|
||||
//! ```
|
||||
|
||||
use buzz_sdk::nip_oa;
|
||||
use buzz_test_client::BuzzTestClient;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
|
||||
fn relay_url() -> String {
|
||||
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string())
|
||||
}
|
||||
|
||||
fn relay_http_url() -> String {
|
||||
relay_url()
|
||||
.replace("wss://", "https://")
|
||||
.replace("ws://", "http://")
|
||||
.trim_end_matches('/')
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Create a fresh channel owned by `owner_keys`, return the channel UUID string.
|
||||
async fn create_agent_owned_channel(agent_keys: &Keys) -> String {
|
||||
let http = reqwest::Client::new();
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
|
||||
Tag::parse(["name", &format!("haec-test-{}", channel_uuid.simple())]).unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "open"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(agent_keys)
|
||||
.unwrap();
|
||||
|
||||
let resp = http
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &agent_keys.public_key().to_hex())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.expect("submit create-channel event");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"channel creation failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(
|
||||
body["accepted"].as_bool().unwrap_or(false),
|
||||
"channel creation not accepted: {body}"
|
||||
);
|
||||
channel_uuid.to_string()
|
||||
}
|
||||
|
||||
/// Build a NIP-OA auth tag for `agent_keys` signed by `owner_keys`.
|
||||
fn make_nip_oa_auth_tag(owner_keys: &Keys, agent_keys: &Keys) -> Tag {
|
||||
let tag_json = nip_oa::compute_auth_tag(owner_keys, &agent_keys.public_key(), "kind=9")
|
||||
.expect("compute_auth_tag");
|
||||
nip_oa::parse_auth_tag(&tag_json).expect("parse_auth_tag")
|
||||
}
|
||||
|
||||
/// Connect `agent_keys` to the relay with NIP-OA, establishing owner→agent in the DB.
|
||||
/// Returns the connected (authenticated) client for the agent.
|
||||
async fn connect_agent_with_owner(agent_keys: &Keys, owner_keys: &Keys) -> BuzzTestClient {
|
||||
let url = relay_url();
|
||||
let auth_tag = make_nip_oa_auth_tag(owner_keys, agent_keys);
|
||||
let mut client = BuzzTestClient::connect_unauthenticated(&url)
|
||||
.await
|
||||
.expect("connect agent unauthenticated");
|
||||
client
|
||||
.authenticate_with_nip_oa(agent_keys, &auth_tag)
|
||||
.await
|
||||
.expect("NIP-OA auth");
|
||||
client
|
||||
}
|
||||
|
||||
// ─── kind:40003 message edit ───────────────────────────────────────────────
|
||||
|
||||
/// Owner can edit a message authored by their agent via kind:40003.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_edit_agent_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership in DB.
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
// Agent sends a message.
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
// Owner sends a kind:40003 edit event targeting the agent's message.
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "corrected content")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner edit of agent message rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// An unrelated third party cannot edit an agent's message.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_edit_agent_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "malicious edit")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to edit agent message, but was accepted"
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// The agent itself can still edit its own message (self-edit unchanged).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_agent_can_self_edit_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "agent self-edit")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = agent_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit");
|
||||
assert!(ok.accepted, "agent self-edit rejected: {}", ok.message);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── kind:9005 DELETE_EVENT ─────────────────────────────────────────────────
|
||||
|
||||
/// Owner can delete a message authored by their agent via kind:9005.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_agent_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9005), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner delete of agent message rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// An unrelated third party cannot delete an agent's message.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_delete_agent_message() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let content = format!("agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message");
|
||||
assert!(ok.accepted, "agent message rejected: {}", ok.message);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9005), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to delete agent message, but was accepted"
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── kind:9002 EDIT_METADATA ────────────────────────────────────────────────
|
||||
|
||||
/// Owner can edit metadata (name/archived) of a channel owned by their agent,
|
||||
/// even when the owner is not a channel member.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_edit_agent_channel_metadata() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
|
||||
// Agent creates the channel (agent is the channel owner-member).
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership.
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Owner sends kind:9002 to rename the channel — owner is NOT a member.
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(9002), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["name", "owner-renamed-channel"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit metadata");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner edit of agent channel metadata rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Owner can archive an agent's channel via kind:9002.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_archive_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let archive_event = EventBuilder::new(Kind::Custom(9002), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["archived", "true"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(archive_event)
|
||||
.await
|
||||
.expect("send archive");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner archive of agent channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Unrelated third party cannot edit metadata of an agent's channel.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_edit_agent_channel_metadata() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let edit_event = EventBuilder::new(Kind::Custom(9002), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["name", "hijacked-name"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to edit agent channel metadata, but was accepted"
|
||||
);
|
||||
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── kind:9008 DELETE_GROUP ─────────────────────────────────────────────────
|
||||
|
||||
/// Owner can delete a channel owned by their agent via kind:9008,
|
||||
/// even when the owner is not a channel member.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership.
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Owner sends kind:9008 to delete the channel — owner is NOT a member.
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9008), "")
|
||||
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete group");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner delete of agent channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Unrelated third party cannot delete an agent's channel.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_third_party_cannot_delete_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let third_party_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut third_party_client = BuzzTestClient::connect(&relay_url(), &third_party_keys)
|
||||
.await
|
||||
.expect("connect third party");
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9008), "")
|
||||
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
|
||||
.sign_with_keys(&third_party_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = third_party_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"third party should NOT be able to delete agent channel, but was accepted"
|
||||
);
|
||||
|
||||
third_party_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Create a fresh **private** channel owned by `agent_keys`, return the channel UUID string.
|
||||
async fn create_private_agent_owned_channel(agent_keys: &Keys) -> String {
|
||||
let http = reqwest::Client::new();
|
||||
let channel_uuid = uuid::Uuid::new_v4();
|
||||
|
||||
let event = EventBuilder::new(Kind::Custom(9007), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_uuid.to_string()]).unwrap(),
|
||||
Tag::parse([
|
||||
"name",
|
||||
&format!("haec-private-test-{}", channel_uuid.simple()),
|
||||
])
|
||||
.unwrap(),
|
||||
Tag::parse(["channel_type", "stream"]).unwrap(),
|
||||
Tag::parse(["visibility", "private"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(agent_keys)
|
||||
.unwrap();
|
||||
|
||||
let resp = http
|
||||
.post(format!("{}/events", relay_http_url()))
|
||||
.header("X-Pubkey", &agent_keys.public_key().to_hex())
|
||||
.header("Content-Type", "application/json")
|
||||
.body(serde_json::to_string(&event).unwrap())
|
||||
.send()
|
||||
.await
|
||||
.expect("submit create-private-channel event");
|
||||
assert!(
|
||||
resp.status().is_success(),
|
||||
"private channel creation failed: {}",
|
||||
resp.status()
|
||||
);
|
||||
let body: serde_json::Value = resp.json().await.unwrap();
|
||||
assert!(
|
||||
body["accepted"].as_bool().unwrap_or(false),
|
||||
"private channel creation not accepted: {body}"
|
||||
);
|
||||
channel_uuid.to_string()
|
||||
}
|
||||
|
||||
// ─── Private-channel coverage (Fix 1 regression guard) ──────────────────────
|
||||
//
|
||||
// These tests verify that the membership-gate bypass works on private channels —
|
||||
// i.e., a non-member owning human can act on private agent-owned content.
|
||||
// Previously all four predicates were unreachable for private channels because
|
||||
// check_channel_membership rejected non-members before the per-kind validators ran.
|
||||
|
||||
/// Owner can edit a message in a private agent-owned channel (non-member owner allowed).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_edit_agent_message_in_private_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership; agent sends a message.
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
let content = format!("private-agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message to private channel");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent message to private channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
let msg_event_id = ok.event_id;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Owner (not a channel member) edits the agent's message.
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "edited content")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let ok = owner_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner edit of agent message in private channel rejected (membership gate not bypassed): {}",
|
||||
ok.message
|
||||
);
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Owner can delete a message in a private agent-owned channel (non-member owner allowed).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_agent_message_in_private_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
let content = format!("private-agent-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = agent_client
|
||||
.send_text_message(&agent_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("agent send message to private channel");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent message to private channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
let msg_event_id = ok.event_id;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9005), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner delete of agent message in private channel rejected (membership gate not bypassed): {}",
|
||||
ok.message
|
||||
);
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Owner can edit metadata of a private agent-owned channel (non-member owner allowed).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_edit_metadata_of_private_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
let edit_event = EventBuilder::new(Kind::Custom(9002), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["name", "owner-renamed-private-channel"]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let ok = owner_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit metadata");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner edit of private agent channel metadata rejected (membership gate not bypassed): {}",
|
||||
ok.message
|
||||
);
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Owner can delete a private agent-owned channel (non-member owner allowed).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_owner_can_delete_private_agent_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
let mut owner_client = BuzzTestClient::connect(&relay_url(), &owner_keys)
|
||||
.await
|
||||
.expect("connect owner");
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9008), "")
|
||||
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
|
||||
.sign_with_keys(&owner_keys)
|
||||
.unwrap();
|
||||
let ok = owner_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete group");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"owner delete of private agent channel rejected (membership gate not bypassed): {}",
|
||||
ok.message
|
||||
);
|
||||
owner_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// Agent itself can still delete its own channel (self-delete unchanged).
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_agent_can_self_delete_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let channel_id = create_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9008), "")
|
||||
.tags(vec![Tag::parse(["h", &channel_id]).unwrap()])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
|
||||
let ok = agent_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send self-delete group");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent self-delete of own channel rejected: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
agent_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
// ─── Removed-author rejection tests (Option A regression guard) ─────────────
|
||||
//
|
||||
// These tests verify that removing a user from a private channel revokes their
|
||||
// ability to edit or delete their own historical messages. Before Option A,
|
||||
// adding 40003/9005 to skip_membership also widened the self-author fast-path,
|
||||
// allowing removed users to mutate private-channel history.
|
||||
|
||||
/// A user removed from a private channel CANNOT edit their own old messages.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_removed_author_cannot_edit_own_message_in_private_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let victim_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership; agent is now in the channel.
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
// Agent adds victim to the private channel via kind:9000 (PUT_USER).
|
||||
let add_event = EventBuilder::new(Kind::Custom(9000), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let ok = agent_client
|
||||
.send_event(add_event)
|
||||
.await
|
||||
.expect("send PUT_USER");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent failed to add victim to private channel: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
// Victim connects and sends a message while still a member.
|
||||
let mut victim_client = BuzzTestClient::connect(&relay_url(), &victim_keys)
|
||||
.await
|
||||
.expect("connect victim");
|
||||
let content = format!("victim-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = victim_client
|
||||
.send_text_message(&victim_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("victim send message");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"victim message rejected while still a member: {}",
|
||||
ok.message
|
||||
);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
// Agent removes victim from the channel via kind:9001 (REMOVE_USER).
|
||||
let remove_event = EventBuilder::new(Kind::Custom(9001), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let ok = agent_client
|
||||
.send_event(remove_event)
|
||||
.await
|
||||
.expect("send REMOVE_USER");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent failed to remove victim from private channel: {}",
|
||||
ok.message
|
||||
);
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Victim (now removed) attempts to edit their old message — must be rejected.
|
||||
let edit_event = EventBuilder::new(Kind::Custom(40003), "edited content after removal")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&victim_keys)
|
||||
.unwrap();
|
||||
let ok = victim_client
|
||||
.send_event(edit_event)
|
||||
.await
|
||||
.expect("send edit attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"removed author should NOT be able to edit old message in private channel, but was accepted"
|
||||
);
|
||||
victim_client.disconnect().await.ok();
|
||||
}
|
||||
|
||||
/// A user removed from a private channel CANNOT delete their own old messages.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn test_removed_author_cannot_delete_own_message_in_private_channel() {
|
||||
let owner_keys = Keys::generate();
|
||||
let agent_keys = Keys::generate();
|
||||
let victim_keys = Keys::generate();
|
||||
let channel_id = create_private_agent_owned_channel(&agent_keys).await;
|
||||
|
||||
// Establish NIP-OA ownership; agent is now in the channel.
|
||||
let mut agent_client = connect_agent_with_owner(&agent_keys, &owner_keys).await;
|
||||
|
||||
// Agent adds victim to the private channel via kind:9000 (PUT_USER).
|
||||
let add_event = EventBuilder::new(Kind::Custom(9000), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let ok = agent_client
|
||||
.send_event(add_event)
|
||||
.await
|
||||
.expect("send PUT_USER");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent failed to add victim to private channel: {}",
|
||||
ok.message
|
||||
);
|
||||
|
||||
// Victim connects and sends a message while still a member.
|
||||
let mut victim_client = BuzzTestClient::connect(&relay_url(), &victim_keys)
|
||||
.await
|
||||
.expect("connect victim");
|
||||
let content = format!("victim-msg-{}", uuid::Uuid::new_v4());
|
||||
let ok = victim_client
|
||||
.send_text_message(&victim_keys, &channel_id, &content, 9)
|
||||
.await
|
||||
.expect("victim send message");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"victim message rejected while still a member: {}",
|
||||
ok.message
|
||||
);
|
||||
let msg_event_id = ok.event_id;
|
||||
|
||||
// Agent removes victim from the channel via kind:9001 (REMOVE_USER).
|
||||
let remove_event = EventBuilder::new(Kind::Custom(9001), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
Tag::parse(["p", &victim_keys.public_key().to_hex()]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&agent_keys)
|
||||
.unwrap();
|
||||
let ok = agent_client
|
||||
.send_event(remove_event)
|
||||
.await
|
||||
.expect("send REMOVE_USER");
|
||||
assert!(
|
||||
ok.accepted,
|
||||
"agent failed to remove victim from private channel: {}",
|
||||
ok.message
|
||||
);
|
||||
agent_client.disconnect().await.ok();
|
||||
|
||||
// Victim (now removed) attempts to delete their old message — must be rejected.
|
||||
let delete_event = EventBuilder::new(Kind::Custom(9005), "")
|
||||
.tags(vec![
|
||||
Tag::parse(["e", &msg_event_id]).unwrap(),
|
||||
Tag::parse(["h", &channel_id]).unwrap(),
|
||||
])
|
||||
.sign_with_keys(&victim_keys)
|
||||
.unwrap();
|
||||
let ok = victim_client
|
||||
.send_event(delete_event)
|
||||
.await
|
||||
.expect("send delete attempt");
|
||||
assert!(
|
||||
!ok.accepted,
|
||||
"removed author should NOT be able to delete old message in private channel, but was accepted"
|
||||
);
|
||||
victim_client.disconnect().await.ok();
|
||||
}
|
||||
@@ -58,6 +58,7 @@ export default defineConfig({
|
||||
"**/overscroll-boundary.spec.ts",
|
||||
"**/cold-switch-longtask.perf.ts",
|
||||
"**/timeline-no-shift.spec.ts",
|
||||
"**/human-edit-agent-content.spec.ts",
|
||||
],
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
|
||||
@@ -29,7 +29,7 @@ type ChannelManagementModerationActionsProps = {
|
||||
isArchived: boolean;
|
||||
isDark: boolean;
|
||||
isDeleteDialogOpen: boolean;
|
||||
isOwner: boolean;
|
||||
canDeleteChannel: boolean;
|
||||
resolvedChannelName: string;
|
||||
unarchiveChannelMutation: ChannelMutation;
|
||||
};
|
||||
@@ -43,7 +43,7 @@ export function ChannelManagementModerationActions({
|
||||
isArchived,
|
||||
isDark,
|
||||
isDeleteDialogOpen,
|
||||
isOwner,
|
||||
canDeleteChannel,
|
||||
resolvedChannelName,
|
||||
unarchiveChannelMutation,
|
||||
}: ChannelManagementModerationActionsProps) {
|
||||
@@ -104,7 +104,7 @@ export function ChannelManagementModerationActions({
|
||||
<Archive className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{isOwner ? (
|
||||
{canDeleteChannel ? (
|
||||
<AlertDialog
|
||||
onOpenChange={handleDeleteDialogOpenChange}
|
||||
open={isDeleteDialogOpen}
|
||||
|
||||
@@ -37,8 +37,11 @@ import {
|
||||
formatTtlDuration,
|
||||
parseTtlDuration,
|
||||
} from "@/features/channels/lib/ephemeralChannel";
|
||||
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
|
||||
import { useUsersBatchQuery } from "@/features/profile/hooks";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { useTheme } from "@/shared/theme/ThemeProvider";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
@@ -133,9 +136,38 @@ export function ChannelManagementSheet({
|
||||
const selfMember =
|
||||
members.find((member) => member.pubkey === currentPubkey) ?? null;
|
||||
const hasResolvedMembership = membersQuery.data !== undefined;
|
||||
const isOwner = selfMember?.role === "owner";
|
||||
|
||||
// Collect owner-role member pubkeys to look up their NIP-OA ownerPubkey.
|
||||
// This is what surfaces the "you own the agent that owns this channel" path.
|
||||
const ownerMemberPubkeys = React.useMemo(
|
||||
() =>
|
||||
members
|
||||
.filter((m) => m.role === "owner" && m.pubkey !== currentPubkey)
|
||||
.map((m) => m.pubkey),
|
||||
[members, currentPubkey],
|
||||
);
|
||||
const ownerProfilesQuery = useUsersBatchQuery(ownerMemberPubkeys, {
|
||||
enabled: open && ownerMemberPubkeys.length > 0,
|
||||
});
|
||||
// True when an owner-role member of this channel is an agent owned by the
|
||||
// current user — mirrors the relay's is_agent_owner gate.
|
||||
const canManageOwnedAgentChannel = React.useMemo(() => {
|
||||
if (!currentPubkey || !ownerProfilesQuery.data) return false;
|
||||
return ownerMemberPubkeys.some((pubkey) =>
|
||||
ownsAuthorAgent(
|
||||
ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)],
|
||||
currentPubkey,
|
||||
),
|
||||
);
|
||||
}, [currentPubkey, ownerMemberPubkeys, ownerProfilesQuery.data]);
|
||||
|
||||
const isSelfOwner = selfMember?.role === "owner";
|
||||
// Capability: may delete this channel (self-owner OR owns the agent-owner).
|
||||
const canDeleteChannel = isSelfOwner || canManageOwnedAgentChannel;
|
||||
const canManageChannel =
|
||||
selfMember?.role === "owner" || selfMember?.role === "admin";
|
||||
selfMember?.role === "owner" ||
|
||||
selfMember?.role === "admin" ||
|
||||
canManageOwnedAgentChannel;
|
||||
const canEditNarrative =
|
||||
canManageChannel && selfMember !== null && detail?.channelType !== "dm";
|
||||
const isArchived =
|
||||
@@ -353,7 +385,7 @@ export function ChannelManagementSheet({
|
||||
isArchived={isArchived}
|
||||
isDark={isDark}
|
||||
isDeleteDialogOpen={isDeleteDialogOpen}
|
||||
isOwner={isOwner}
|
||||
canDeleteChannel={canDeleteChannel}
|
||||
mode={auxiliaryPanelMode}
|
||||
transparentChrome={transparentChrome}
|
||||
joinChannelMutation={joinChannelMutation}
|
||||
@@ -399,7 +431,7 @@ export function ChannelManagementSheet({
|
||||
isArchived={isArchived}
|
||||
isDark={isDark}
|
||||
isDeleteDialogOpen={isDeleteDialogOpen}
|
||||
isOwner={isOwner}
|
||||
canDeleteChannel={canDeleteChannel}
|
||||
mode={auxiliaryPanelMode}
|
||||
transparentChrome={transparentChrome}
|
||||
joinChannelMutation={joinChannelMutation}
|
||||
@@ -635,7 +667,7 @@ type ChannelManagementPanelContentProps = {
|
||||
isArchived: boolean;
|
||||
isDark: boolean;
|
||||
isDeleteDialogOpen: boolean;
|
||||
isOwner: boolean;
|
||||
canDeleteChannel: boolean; // true when caller may delete the channel
|
||||
mode: AuxiliaryPanelMode;
|
||||
transparentChrome?: boolean;
|
||||
joinChannelMutation: ChannelMutation;
|
||||
@@ -667,7 +699,7 @@ function ChannelManagementPanelContent({
|
||||
isArchived,
|
||||
isDark,
|
||||
isDeleteDialogOpen,
|
||||
isOwner,
|
||||
canDeleteChannel,
|
||||
mode,
|
||||
transparentChrome = false,
|
||||
joinChannelMutation,
|
||||
@@ -933,7 +965,7 @@ function ChannelManagementPanelContent({
|
||||
isArchived={isArchived}
|
||||
isDark={isDark}
|
||||
isDeleteDialogOpen={isDeleteDialogOpen}
|
||||
isOwner={isOwner}
|
||||
canDeleteChannel={canDeleteChannel}
|
||||
resolvedChannelName={resolvedChannel.name}
|
||||
unarchiveChannelMutation={unarchiveChannelMutation}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
|
||||
|
||||
/**
|
||||
* Returns true when the current user may edit or delete `message`.
|
||||
*
|
||||
* Two paths grant permission — mirroring the relay's authz:
|
||||
* 1. Self-author: the current user pubkey matches the message pubkey.
|
||||
* 2. Owner-of-agent: the message author's profile carries an `ownerPubkey`
|
||||
* (NIP-OA owner record) equal to the current user's pubkey.
|
||||
*
|
||||
* Huddle-started messages are immutable regardless of authorship.
|
||||
*/
|
||||
export function canManageMessageForCurrentUser(
|
||||
message: TimelineMessage,
|
||||
currentPubkey: string | undefined,
|
||||
profiles: UserProfileLookup | undefined,
|
||||
): boolean {
|
||||
if (message.kind === KIND_HUDDLE_STARTED) return false;
|
||||
if (!currentPubkey || !message.pubkey) return false;
|
||||
if (normalizePubkey(message.pubkey) === normalizePubkey(currentPubkey))
|
||||
return true;
|
||||
return ownsAuthorAgent(
|
||||
profiles?.[normalizePubkey(message.pubkey)],
|
||||
currentPubkey,
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type MainTimelineEntry,
|
||||
} from "@/features/messages/lib/threadPanel";
|
||||
import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown";
|
||||
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
@@ -103,17 +104,6 @@ type MessageThreadPanelSkeletonProps = {
|
||||
transparentChrome?: boolean;
|
||||
};
|
||||
|
||||
function canManageMessage(
|
||||
message: TimelineMessage,
|
||||
currentPubkey: string | undefined,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
currentPubkey &&
|
||||
message.pubkey &&
|
||||
currentPubkey.toLowerCase() === message.pubkey.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function hasLaterVisibleSibling(
|
||||
entries: readonly MainTimelineEntry[],
|
||||
entryIndex: number,
|
||||
@@ -583,12 +573,22 @@ export function MessageThreadPanel({
|
||||
layoutVariant="thread-reply"
|
||||
message={threadHead}
|
||||
onDelete={
|
||||
onDelete && canManageMessage(threadHead, currentPubkey)
|
||||
onDelete &&
|
||||
canManageMessageForCurrentUser(
|
||||
threadHead,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit && canManageMessage(threadHead, currentPubkey)
|
||||
onEdit &&
|
||||
canManageMessageForCurrentUser(
|
||||
threadHead,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
@@ -717,13 +717,21 @@ export function MessageThreadPanel({
|
||||
}
|
||||
onDelete={
|
||||
onDelete &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
canManageMessageForCurrentUser(
|
||||
entry.message,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
)
|
||||
? onDelete
|
||||
: undefined
|
||||
}
|
||||
onEdit={
|
||||
onEdit &&
|
||||
canManageMessage(entry.message, currentPubkey)
|
||||
canManageMessageForCurrentUser(
|
||||
entry.message,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
)
|
||||
? onEdit
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
hasVideoAttachment,
|
||||
} from "@/features/messages/lib/videoReviewContext";
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
import { canManageMessageForCurrentUser } from "@/features/messages/lib/canManageMessage";
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { DayDivider } from "./DayDivider";
|
||||
import { MessageRow } from "./MessageRow";
|
||||
@@ -335,21 +335,13 @@ function MessageRowItem({
|
||||
videoReviewContext,
|
||||
}: MessageRowItemProps) {
|
||||
const { message, summary } = entry;
|
||||
const isMutableMessage = message.kind !== KIND_HUDDLE_STARTED;
|
||||
const canDelete =
|
||||
isMutableMessage &&
|
||||
onDelete &&
|
||||
currentPubkey &&
|
||||
message.pubkey === currentPubkey
|
||||
? onDelete
|
||||
: undefined;
|
||||
const canEdit =
|
||||
isMutableMessage &&
|
||||
onEdit &&
|
||||
currentPubkey &&
|
||||
message.pubkey === currentPubkey
|
||||
? onEdit
|
||||
: undefined;
|
||||
const canManage = canManageMessageForCurrentUser(
|
||||
message,
|
||||
currentPubkey,
|
||||
profiles,
|
||||
);
|
||||
const canDelete = canManage && onDelete ? onDelete : undefined;
|
||||
const canEdit = canManage && onEdit ? onEdit : undefined;
|
||||
|
||||
if (summary && onReply) {
|
||||
const isHighlighted = message.id === highlightedMessageId;
|
||||
|
||||
@@ -85,6 +85,23 @@ export function resolveUserLabel(input: {
|
||||
return truncatePubkey(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current user owns the agent that authored a message.
|
||||
* Mirrors the relay's `is_agent_owner` gate: ownership is determined by the
|
||||
* NIP-OA `ownerPubkey` field on the author's profile, NOT by the local
|
||||
* managed-agents list (which can diverge from server-side ownership).
|
||||
*/
|
||||
export function ownsAuthorAgent(
|
||||
profile: { ownerPubkey: string | null } | undefined,
|
||||
currentPubkey: string | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
!!currentPubkey &&
|
||||
!!profile?.ownerPubkey &&
|
||||
normalizePubkey(profile.ownerPubkey) === normalizePubkey(currentPubkey)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveUserSecondaryLabel(input: {
|
||||
pubkey: string;
|
||||
profiles?: UserProfileLookup;
|
||||
|
||||
@@ -6235,6 +6235,23 @@ async function handleSendManagedAgentChannelMessage(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the `delete_message` Tauri command. Removes the event from the
|
||||
* in-memory mock store so the query-cache invalidation in
|
||||
* `useDeleteMessageMutation.onSuccess` (which filters by eventId) finds
|
||||
* nothing to keep, and the row disappears from the timeline.
|
||||
*/
|
||||
function handleDeleteMessage(args: {
|
||||
channelId: string;
|
||||
eventId: string;
|
||||
}): void {
|
||||
const history = mockMessages.get(args.channelId);
|
||||
if (history) {
|
||||
const index = history.findIndex((ev) => ev.id === args.eventId);
|
||||
if (index !== -1) history.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock the `edit_message` Tauri command. Mirrors the real Rust command
|
||||
* (`build_message_edit`): emit a kind:40003 edit event carrying `["e", target]`
|
||||
@@ -7478,6 +7495,11 @@ export function maybeInstallE2eTauriMocks() {
|
||||
payload as Parameters<typeof handleSendManagedAgentChannelMessage>[0],
|
||||
activeConfig,
|
||||
);
|
||||
case "delete_message":
|
||||
handleDeleteMessage(
|
||||
payload as Parameters<typeof handleDeleteMessage>[0],
|
||||
);
|
||||
return null;
|
||||
case "edit_message":
|
||||
return handleEditMessage(
|
||||
payload as Parameters<typeof handleEditMessage>[0],
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
|
||||
// Fixed pubkey for the owned managed agent seeded in these tests.
|
||||
// Must not collide with any existing e2eBridge constant.
|
||||
const OWNED_AGENT_PUBKEY =
|
||||
"a0b1c2d3e4f5061728394a5b6c7d8e9f0a1b2c3d4e5f6071829304a5b6c7d8e";
|
||||
|
||||
// #random is owned by alice; the mock identity is a plain member.
|
||||
// This is the isolation fixture for the canManageOwnedAgentChannel path —
|
||||
// selfMember.role is "member" not "owner", so without the new gate the
|
||||
// Edit button would not appear.
|
||||
const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
|
||||
|
||||
// Mock-bridge helper: wait for the bridge to initialise, then invoke a command.
|
||||
async function invoke(
|
||||
page: import("@playwright/test").Page,
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
await page.waitForFunction(() =>
|
||||
Boolean(
|
||||
(
|
||||
window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
||||
}
|
||||
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__,
|
||||
),
|
||||
);
|
||||
return page.evaluate(
|
||||
async ({ cmd, p }) => {
|
||||
const win = window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
||||
command: string,
|
||||
payload?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
const fn = win.__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
|
||||
if (!fn) throw new Error("Mock bridge unavailable");
|
||||
return fn(cmd, p);
|
||||
},
|
||||
{ cmd: command, p: payload },
|
||||
);
|
||||
}
|
||||
|
||||
// Open the more-actions menu for a message row and wait for the menu to mount.
|
||||
async function openMoreActionsMenu(
|
||||
page: import("@playwright/test").Page,
|
||||
messageId: string,
|
||||
) {
|
||||
const row = page.locator(`[data-message-id="${messageId}"]`);
|
||||
await row.hover();
|
||||
await page.getByTestId(`more-actions-${messageId}`).click();
|
||||
// Wait for dropdown content to mount — any menu item signals it's open.
|
||||
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await installMockBridge(page, {
|
||||
managedAgents: [
|
||||
{
|
||||
// OwnedBot: a managed agent owned by the mock identity.
|
||||
// The bridge automatically sets owner_pubkey = MOCK_IDENTITY_PUBKEY
|
||||
// in mockProfiles when a managed agent is seeded.
|
||||
pubkey: OWNED_AGENT_PUBKEY,
|
||||
name: "OwnedBot",
|
||||
personaId: "builtin:fizz",
|
||||
status: "running",
|
||||
// Seed into #agents so the bridge seeds a message from this agent.
|
||||
channelNames: ["agents"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Message gate ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("owner can edit their owned agent's message", async ({ page }) => {
|
||||
// The bridge seeds a message from each managed agent in its channels:
|
||||
// id: `mock-agents-managed-${pubkey.slice(0, 8)}`
|
||||
const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`;
|
||||
const editedContent = `Edited by owner ${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
||||
|
||||
// Wait for the agent's seeded message to appear.
|
||||
const agentRow = page.locator(`[data-message-id="${messageId}"]`);
|
||||
await expect(agentRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Open the more-actions menu and click Edit message.
|
||||
await openMoreActionsMenu(page, messageId);
|
||||
await page.getByTestId(`edit-message-${messageId}`).click();
|
||||
|
||||
// Edit banner must appear confirming edit mode is active.
|
||||
await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Clear the composer and type the new content, then submit.
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.clear();
|
||||
await input.fill(editedContent);
|
||||
await input.press("Enter");
|
||||
|
||||
// Edit mode must exit (banner gone) and the updated content must render.
|
||||
await expect(page.getByTestId("edit-target")).toBeHidden();
|
||||
await expect(page.getByTestId("message-timeline")).toContainText(
|
||||
editedContent,
|
||||
);
|
||||
});
|
||||
|
||||
test("owner can delete their owned agent's message", async ({ page }) => {
|
||||
const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
||||
|
||||
const agentRow = page.locator(`[data-message-id="${messageId}"]`);
|
||||
await expect(agentRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Open the more-actions menu and click Delete message.
|
||||
await openMoreActionsMenu(page, messageId);
|
||||
await page.getByTestId(`delete-message-${messageId}`).click();
|
||||
|
||||
// Confirm the deletion in the AlertDialog.
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5_000 });
|
||||
// The destructive confirm button inside the dialog.
|
||||
await page
|
||||
.getByRole("alertdialog")
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
|
||||
// The message row must be removed from the timeline.
|
||||
await expect(agentRow).toBeHidden({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("owner does NOT see Edit or Delete for an unowned agent's message", async ({
|
||||
page,
|
||||
}) => {
|
||||
// "mock-agents-charlie" is seeded in #agents for CHARLIE_PUBKEY.
|
||||
// Charlie is in mockAgentPubkeys but ownerPubkey is NOT the mock identity.
|
||||
const charlieMessageId = "mock-agents-charlie";
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
||||
|
||||
const charlieRow = page.locator(`[data-message-id="${charlieMessageId}"]`);
|
||||
await expect(charlieRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Open the more-actions menu — it must open without Edit or Delete items.
|
||||
await charlieRow.hover();
|
||||
await page.getByTestId(`more-actions-${charlieMessageId}`).click();
|
||||
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
await expect(
|
||||
page.getByTestId(`edit-message-${charlieMessageId}`),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByTestId(`delete-message-${charlieMessageId}`),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
// ─── Thread-panel gate ────────────────────────────────────────────────────────
|
||||
|
||||
test("owner can delete their owned agent's message from the thread panel", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The bridge seeds a message from each managed agent in its channels:
|
||||
// id: `mock-agents-managed-${pubkey.slice(0, 8)}`
|
||||
const messageId = `mock-agents-managed-${OWNED_AGENT_PUBKEY.slice(0, 8)}`;
|
||||
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-agents").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("agents");
|
||||
|
||||
// Wait for the agent's seeded message to appear in the main timeline.
|
||||
const agentRow = page.locator(`[data-message-id="${messageId}"]`).first();
|
||||
await expect(agentRow).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Open the thread panel by hovering the message and clicking Reply.
|
||||
await agentRow.hover();
|
||||
await agentRow.getByRole("button", { name: "Reply" }).click();
|
||||
|
||||
// Wait for the thread panel and confirm the thread head contains the agent message.
|
||||
const threadPanel = page.getByTestId("message-thread-panel");
|
||||
await expect(threadPanel).toBeVisible({ timeout: 5_000 });
|
||||
const threadHead = threadPanel.getByTestId("message-thread-head");
|
||||
await expect(
|
||||
threadHead.locator(`[data-message-id="${messageId}"]`),
|
||||
).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Open the more-actions menu for the thread head from inside the thread panel.
|
||||
const headRow = threadHead.locator(`[data-message-id="${messageId}"]`);
|
||||
await headRow.hover();
|
||||
await threadHead.getByTestId(`more-actions-${messageId}`).click();
|
||||
// Wait for the dropdown to mount.
|
||||
await expect(page.locator('[role="menuitem"]').first()).toBeVisible({
|
||||
timeout: 5_000,
|
||||
});
|
||||
|
||||
// Click Delete.
|
||||
// Radix dropdown portals render outside the thread-panel DOM boundary, so we
|
||||
// query from the full page. Only one menu is open, making the testId unique.
|
||||
await page.getByTestId(`delete-message-${messageId}`).click();
|
||||
|
||||
// The AlertDialog must appear. This proves canManageMessageForCurrentUser is
|
||||
// wired into MessageThreadPanel.tsx — the delete handler is reachable from
|
||||
// the thread-panel path (i.e. the thread-panel permission gate is not
|
||||
// accidentally more restrictive than the main-timeline gate). The full
|
||||
// delete-and-cache-invalidation path is already covered by the main-timeline
|
||||
// delete test above; asserting the dialog here is sufficient as a thread-panel
|
||||
// regression guard.
|
||||
await expect(page.getByRole("alertdialog")).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Dismiss without confirming — leaves the fixture clean for any subsequent steps.
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("alertdialog")).not.toBeVisible({
|
||||
timeout: 3_000,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Channel management gate ──────────────────────────────────────────────────
|
||||
|
||||
test("owner can edit channel name via owned-agent-owner path", async ({
|
||||
page,
|
||||
}) => {
|
||||
const newChannelName = `renamed-${Date.now()}`;
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Add OwnedBot as an owner-role member of #random.
|
||||
// In #random the mock identity is only a plain member — selfMember.role !== "owner".
|
||||
// This isolates canManageOwnedAgentChannel as the sole reason Edit appears.
|
||||
await invoke(page, "add_channel_members", {
|
||||
channelId: RANDOM_CHANNEL_ID,
|
||||
pubkeys: [OWNED_AGENT_PUBKEY],
|
||||
role: "owner",
|
||||
});
|
||||
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
|
||||
// Edit quick-action must be visible: canManageOwnedAgentChannel is true.
|
||||
const editButton = page.getByTestId("channel-management-edit");
|
||||
await expect(editButton).toBeVisible({ timeout: 5_000 });
|
||||
await editButton.click();
|
||||
|
||||
// Change the channel name and save.
|
||||
const nameInput = page.getByTestId("channel-management-name");
|
||||
await expect(nameInput).toBeVisible({ timeout: 5_000 });
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(newChannelName);
|
||||
await page.getByTestId("channel-management-save-changes").click();
|
||||
|
||||
// The edit dialog must close and the updated name must appear in the header.
|
||||
await expect(page.getByTestId("channel-management-name")).toBeHidden();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(newChannelName);
|
||||
});
|
||||
|
||||
test("owner does NOT see channel Edit button when no owned agent is a channel owner", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
|
||||
// #random has alice as owner; mock identity is a plain member.
|
||||
// OwnedBot is NOT added as owner in this test — Edit must not appear.
|
||||
await page.getByTestId("channel-random").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("random");
|
||||
await page.getByTestId("channel-management-trigger").click();
|
||||
await expect(page.getByTestId("channel-management-sheet")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("channel-management-edit")).toBeHidden();
|
||||
});
|
||||
Reference in New Issue
Block a user