Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/buzz-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1638,6 +1638,51 @@ pub async fn update_channel(
get_channel(pool, community_id, channel_id).await
}

/// Atomically updates a channel name and returns `(previous_name, name)`.
///
/// The row lock makes the pair suitable for an audit/system event: concurrent
/// renames observe each other's committed name rather than both reporting the
/// same stale previous value.
pub async fn update_channel_name(
pool: &PgPool,
community_id: CommunityId,
channel_id: Uuid,
name: &str,
) -> Result<(String, String)> {
let name = buzz_core::channel::canonical_channel_name(name);
if name.is_empty() {
return Err(DbError::InvalidData("channel name is required".into()));
}

let mut tx = pool.begin().await?;
let previous_name = sqlx::query_scalar::<_, String>(
"SELECT name FROM channels \
WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL \
FOR UPDATE",
)
.bind(community_id.as_uuid())
.bind(channel_id)
.fetch_optional(&mut *tx)
.await?
.ok_or(DbError::ChannelNotFound(channel_id))?;

let result = sqlx::query(
"UPDATE channels SET name = $1, updated_at = NOW() \
WHERE community_id = $2 AND id = $3 AND deleted_at IS NULL",
)
.bind(name)
.bind(community_id.as_uuid())
.bind(channel_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() == 0 {
return Err(DbError::ChannelNotFound(channel_id));
}

tx.commit().await?;
Ok((previous_name, name.to_owned()))
}

/// Sets the topic for a channel, recording who set it and when.
pub async fn set_topic(
pool: &PgPool,
Expand Down
11 changes: 11 additions & 0 deletions crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2563,6 +2563,17 @@ impl Db {
channel::update_channel(&self.pool, community_id, channel_id, updates).await
}

/// Atomically updates a channel name and returns the previous and new names.
#[datastore_span(name = "update_channel_name", system = "postgresql")]
pub async fn update_channel_name(
&self,
community_id: CommunityId,
channel_id: Uuid,
name: &str,
) -> Result<(String, String)> {
channel::update_channel_name(&self.pool, community_id, channel_id, name).await
}

/// Sets the topic for a channel.
#[datastore_span(name = "set_topic", system = "postgresql")]
pub async fn set_topic(
Expand Down
51 changes: 44 additions & 7 deletions crates/buzz-relay/src/handlers/side_effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1486,6 +1486,19 @@ async fn handle_remove_user(
Ok(())
}

fn should_emit_channel_name_change(previous_name: &str, name: &str) -> bool {
previous_name != name
}

fn channel_name_change_content(actor: &str, previous_name: &str, name: &str) -> serde_json::Value {
serde_json::json!({
"type": "name_changed",
"actor": actor,
"name": name,
"previous_name": previous_name,
})
}

async fn handle_edit_metadata(
tenant: &TenantContext,
event: &Event,
Expand All @@ -1501,17 +1514,26 @@ async fn handle_edit_metadata(
if let Some(val) = tag.content() {
match key.as_str() {
"name" => {
state
let (previous_channel_name, updated_channel_name) = state
.db
.update_channel(
tenant.community(),
.update_channel_name(tenant.community(), channel_id, val)
.await?;
if should_emit_channel_name_change(
&previous_channel_name,
&updated_channel_name,
) {
emit_system_message(
tenant,
state,
channel_id,
buzz_db::channel::ChannelUpdate {
name: Some(val.to_string()),
..Default::default()
},
channel_name_change_content(
&actor_hex,
&previous_channel_name,
&updated_channel_name,
),
)
.await?;
}
}
"about" => {
state
Expand Down Expand Up @@ -3618,6 +3640,21 @@ mod tests {
}));
}

#[test]
fn channel_name_change_carries_actor_and_both_names() {
let content = channel_name_change_content("actor", "old-name", "new-name");

assert_eq!(content["type"], "name_changed");
assert_eq!(content["actor"], "actor");
assert_eq!(content["previous_name"], "old-name");
assert_eq!(content["name"], "new-name");
}

#[test]
fn channel_name_change_is_not_emitted_when_stored_names_match() {
assert!(!should_emit_channel_name_change("same-name", "same-name"));
}

#[test]
fn delete_tombstone_omits_absent_moderation_metadata() {
let content =
Expand Down
178 changes: 178 additions & 0 deletions crates/buzz-test-client/tests/e2e_relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ fn sub_id(name: &str) -> String {
format!("e2e-{name}-{}", uuid::Uuid::new_v4())
}

async fn assert_no_event_of_kind(
client: &mut BuzzTestClient,
kind: Kind,
timeout_dur: Duration,
context: &str,
) {
let deadline = tokio::time::Instant::now() + timeout_dur;
loop {
let remaining = deadline
.checked_duration_since(tokio::time::Instant::now())
.unwrap_or(Duration::ZERO);
if remaining.is_zero() {
return;
}

match client.recv_event(remaining).await {
Err(TestClientError::Timeout) => return,
Ok(RelayMessage::Event { event, .. }) if event.kind == kind => {
panic!("{context}: {}", event.id)
}
Ok(_) => {}
Err(error) => panic!("{context}: unexpected receive error: {error}"),
}
}
}

fn relay_http_url() -> String {
relay_url()
.replace("wss://", "https://")
Expand Down Expand Up @@ -207,6 +233,158 @@ async fn create_test_channel(keys: &Keys) -> String {
channel_uuid.to_string()
}

/// A successful kind:9002 name edit emits one relay-signed kind:40099 event,
/// delivers it to live subscribers, and persists it for later subscriptions.
#[tokio::test]
#[ignore]
async fn test_channel_rename_emits_persistent_system_message() {
let url = relay_url();
let owner_keys = Keys::generate();
let channel = create_test_channel(&owner_keys).await;
let mut client = BuzzTestClient::connect(&url, &owner_keys)
.await
.expect("connect as channel owner");

let live_sid = sub_id("channel-rename-live");
let filter = Filter::new()
.kind(Kind::Custom(40099))
.custom_tags(SingleLetterTag::lowercase(Alphabet::H), [channel.as_str()]);
client
.subscribe(&live_sid, vec![filter.clone()])
.await
.expect("subscribe to system messages");
client
.collect_until_eose(&live_sid, Duration::from_secs(5))
.await
.expect("system message EOSE");

let new_name = format!("renamed-{}", Uuid::new_v4());
let rename = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", &channel]).expect("h tag"),
Tag::parse(["name", &new_name]).expect("name tag"),
])
.sign_with_keys(&owner_keys)
.expect("sign rename event");
let ok = client.send_event(rename).await.expect("send rename event");
assert!(ok.accepted, "rename rejected: {}", ok.message);

let live_event = loop {
match client
.recv_event(Duration::from_secs(5))
.await
.expect("receive live system message")
{
RelayMessage::Event { event, .. } if event.kind == Kind::Custom(40099) => {
break event;
}
_ => {}
}
};
buzz_core::verify_event(&live_event).expect("system message signature");
let live_content: serde_json::Value =
serde_json::from_str(&live_event.content).expect("system message JSON");
assert_eq!(live_content["type"], "name_changed");
assert_eq!(live_content["actor"], owner_keys.public_key().to_hex());
assert_eq!(
live_content["previous_name"],
format!("relay-e2e-{channel}")
);
assert_eq!(live_content["name"], new_name);

let persisted_sid = sub_id("channel-rename-persisted");
client
.subscribe(&persisted_sid, vec![filter.clone()])
.await
.expect("subscribe for persisted system message");
let persisted = client
.collect_until_eose(&persisted_sid, Duration::from_secs(5))
.await
.expect("persisted system message EOSE");
assert_eq!(
persisted
.iter()
.filter(|event| event.id == live_event.id)
.count(),
1,
"rename system message should be persisted exactly once"
);

// A display-equivalent rename is accepted and stored canonically, but it
// must not emit another system message.
let equivalent_name = format!(" ###{new_name} ");
let no_op_rename = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", &channel]).expect("h tag"),
Tag::parse(["name", &equivalent_name]).expect("name tag"),
])
.sign_with_keys(&owner_keys)
.expect("sign canonical no-op rename event");
let no_op = client
.send_event(no_op_rename)
.await
.expect("send canonical no-op rename event");
assert!(
no_op.accepted,
"canonical no-op rename rejected: {}",
no_op.message
);

assert_no_event_of_kind(
&mut client,
Kind::Custom(40099),
Duration::from_millis(500),
"canonical no-op rename emitted system message",
)
.await;

let no_op_persisted_sid = sub_id("channel-rename-no-op-persisted");
client
.subscribe(&no_op_persisted_sid, vec![filter])
.await
.expect("subscribe after canonical no-op rename");
let persisted_after_no_op = client
.collect_until_eose(&no_op_persisted_sid, Duration::from_secs(5))
.await
.expect("canonical no-op persistence EOSE");
let name_change_count = persisted_after_no_op
.iter()
.filter(|event| {
serde_json::from_str::<serde_json::Value>(&event.content)
.is_ok_and(|content| content["type"] == "name_changed")
})
.count();
assert_eq!(
name_change_count, 1,
"canonical no-op rename should not persist another system message"
);

// Invalid names are rejected before the metadata side effect runs, so a
// failed update must not produce a second system message.
let invalid_rename = EventBuilder::new(Kind::Custom(9002), "")
.tags([
Tag::parse(["h", &channel]).expect("h tag"),
Tag::parse(["name", "### "]).expect("name tag"),
])
.sign_with_keys(&owner_keys)
.expect("sign invalid rename event");
let rejected = client
.send_event(invalid_rename)
.await
.expect("send invalid rename event");
assert!(!rejected.accepted, "invalid rename should be rejected");

assert_no_event_of_kind(
&mut client,
Kind::Custom(40099),
Duration::from_millis(500),
"failed rename emitted system message",
)
.await;

client.disconnect().await.expect("disconnect");
}

#[tokio::test]
#[ignore]
async fn test_connect_and_authenticate() {
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/features/messages/lib/systemEventCopy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,24 @@ import test from "node:test";
import {
addedByActionPrefix,
describeChannelTextFieldChange,
describeChannelNameChange,
toInlineName,
} from "./systemEventCopy.ts";

test("a channel rename names both the old and new names", () => {
assert.equal(
describeChannelNameChange("old-name", "new-name"),
"renamed the channel from “old-name” to “new-name”",
);
});

test("a channel rename falls back to the new name when the old name is unavailable", () => {
assert.equal(
describeChannelNameChange(undefined, "new-name"),
"renamed the channel to “new-name”",
);
});

test("an add to the reader uses passive wording", () => {
assert.equal(addedByActionPrefix(true), "were added by");
assert.equal(addedByActionPrefix(false), "added by");
Expand Down
10 changes: 10 additions & 0 deletions desktop/src/features/messages/lib/systemEventCopy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@ const CLOSE_QUOTE = "”";

export type ChannelTextField = "topic" | "purpose";

export function describeChannelNameChange(
previousName: string | undefined,
name: string,
): string {
if (previousName === undefined) {
return `renamed the channel to ${OPEN_QUOTE}${name}${CLOSE_QUOTE}`;
}
return `renamed the channel from ${OPEN_QUOTE}${previousName}${CLOSE_QUOTE} to ${OPEN_QUOTE}${name}${CLOSE_QUOTE}`;
}

/**
* The reader is the recipient of an add, while every other member is the
* subject of one. Keep that distinction in the caption: "You were added by"
Expand Down
Loading