use std::collections::{BTreeMap, BTreeSet};
use mdk_storage_traits::GroupId;
use mdk_storage_traits::MdkStorageProvider;
use mdk_storage_traits::groups::types as group_types;
use mdk_storage_traits::messages::types as message_types;
use nostr::prelude::*;
use openmls::prelude::*;
use openmls::treesync::errors::LeafNodeValidationError;
use openmls_basic_credential::SignatureKeyPair;
use tls_codec::Serialize as TlsSerialize;
use sha2::{Digest, Sha256};
mod openmls_compat;
use self::openmls_compat::exported_leaf_capabilities;
use super::MDK;
use super::extension::NostrGroupDataExtension;
use crate::constant::{GROUP_CONTEXT_REQUIRED_EXTENSIONS, SUPPORTED_PROPOSALS};
use crate::error::Error;
use crate::messages::EventTag;
use crate::messages::crypto::encrypt_message_with_exporter_secret;
use crate::util::{ContentEncoding, encode_content};
#[derive(Debug)]
pub struct GroupResult {
pub group: group_types::Group,
pub welcome_rumors: Vec<UnsignedEvent>,
}
#[derive(Debug)]
pub struct UpdateGroupResult {
pub evolution_event: Event,
pub welcome_rumors: Option<Vec<UnsignedEvent>>,
pub mls_group_id: GroupId,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProposalUpgradability {
AlreadyRequired,
Available,
Blocked {
blockers: Vec<PublicKey>,
},
}
#[derive(Debug, Clone)]
pub struct CapabilityUpgradeStatus {
pub per_proposal: Vec<(ProposalType, ProposalUpgradability)>,
}
#[derive(Debug, Clone)]
pub struct MemberCapabilities {
pub member: PublicKey,
pub is_admin: bool,
pub proposals: BTreeSet<ProposalType>,
pub extensions: BTreeSet<ExtensionType>,
pub ciphersuites: Vec<VerifiableCiphersuite>,
}
#[derive(Debug, Clone)]
pub struct NostrGroupConfigData {
pub name: String,
pub description: String,
pub image_hash: Option<[u8; 32]>,
pub image_key: Option<[u8; 32]>,
pub image_nonce: Option<[u8; 12]>,
pub relays: Vec<RelayUrl>,
pub admins: Vec<PublicKey>,
}
#[derive(Debug, Clone, Default)]
pub struct NostrGroupDataUpdate {
pub name: Option<String>,
pub description: Option<String>,
pub image_hash: Option<Option<[u8; 32]>>,
pub image_key: Option<Option<[u8; 32]>>,
pub image_nonce: Option<Option<[u8; 12]>>,
pub image_upload_key: Option<Option<[u8; 32]>>,
pub relays: Option<Vec<RelayUrl>>,
pub admins: Option<Vec<PublicKey>>,
pub nostr_group_id: Option<[u8; 32]>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PendingMemberChanges {
pub additions: Vec<PublicKey>,
pub removals: Vec<PublicKey>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LeafNodeInfo {
pub index: u32,
pub encryption_key: String,
pub signature_key: String,
pub credential_identity: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RatchetTreeInfo {
pub tree_hash: String,
pub serialized_tree: String,
pub leaf_nodes: Vec<LeafNodeInfo>,
}
impl NostrGroupConfigData {
pub fn new(
name: String,
description: String,
image_hash: Option<[u8; 32]>,
image_key: Option<[u8; 32]>,
image_nonce: Option<[u8; 12]>,
relays: Vec<RelayUrl>,
admins: Vec<PublicKey>,
) -> Self {
Self {
name,
description,
image_hash,
image_key,
image_nonce,
relays,
admins,
}
}
}
impl NostrGroupDataUpdate {
pub fn new() -> Self {
Self::default()
}
mdk_macros::setters! {
name: impl Into<String>;
description: impl Into<String>;
image_hash: Option<[u8; 32]>;
image_key: Option<[u8; 32]>;
image_nonce: Option<[u8; 12]>;
image_upload_key: Option<[u8; 32]>;
relays: Vec<RelayUrl>;
admins: Vec<PublicKey>;
nostr_group_id: [u8; 32];
}
}
impl<Storage> MDK<Storage>
where
Storage: MdkStorageProvider,
{
pub(crate) fn pubkey_from_credential(
&self,
credential: &Credential,
) -> Result<PublicKey, Error> {
let basic = BasicCredential::try_from(credential.clone())?;
self.parse_credential_identity(basic.identity())
}
pub(crate) fn get_own_pubkey(&self, group: &MlsGroup) -> Result<PublicKey, Error> {
let own_leaf = group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
self.pubkey_from_credential(own_leaf.credential())
}
pub(crate) fn is_leaf_node_admin(
&self,
group_id: &GroupId,
leaf_node: &LeafNode,
) -> Result<bool, Error> {
let pubkey = self.pubkey_for_leaf_node(leaf_node)?;
let mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let group_data = NostrGroupDataExtension::from_group(&mls_group)?;
Ok(group_data.admins.contains(&pubkey))
}
pub(crate) fn pubkey_for_leaf_node(&self, leaf_node: &LeafNode) -> Result<PublicKey, Error> {
self.pubkey_from_credential(leaf_node.credential())
}
pub(crate) fn pubkey_for_member(&self, member: &Member) -> Result<PublicKey, Error> {
self.pubkey_from_credential(&member.credential)
}
pub(crate) fn load_mls_signer(&self, group: &MlsGroup) -> Result<SignatureKeyPair, Error> {
let own_leaf: &LeafNode = group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
let public_key: &[u8] = own_leaf.signature_key().as_slice();
SignatureKeyPair::read(
self.provider.storage(),
public_key,
group.ciphersuite().signature_algorithm(),
)
.ok_or(Error::CantLoadSigner)
}
fn load_mls_group_impl(&self, group_id: &GroupId) -> Result<Option<MlsGroup>, Error> {
MlsGroup::load(self.provider.storage(), group_id.inner())
.map_err(|e| Error::Provider(e.to_string()))
}
#[cfg(feature = "debug-examples")]
pub fn load_mls_group(&self, group_id: &GroupId) -> Result<Option<MlsGroup>, Error> {
self.load_mls_group_impl(group_id)
}
#[cfg(not(feature = "debug-examples"))]
pub(crate) fn load_mls_group(&self, group_id: &GroupId) -> Result<Option<MlsGroup>, Error> {
self.load_mls_group_impl(group_id)
}
fn derive_exporter_secret_for_group(
&self,
group_id: &crate::GroupId,
group: &MlsGroup,
exporter_label: &str,
exporter_context: &[u8],
) -> Result<group_types::GroupExporterSecret, Error> {
let export_secret: [u8; 32] = group
.export_secret(self.provider.crypto(), exporter_label, exporter_context, 32)?
.try_into()
.map_err(|_| Error::Group("Failed to convert export secret to [u8; 32]".to_string()))?;
Ok(group_types::GroupExporterSecret {
mls_group_id: group_id.clone(),
epoch: group.epoch().as_u64(),
secret: mdk_storage_traits::Secret::new(export_secret),
})
}
pub(crate) fn legacy_exporter_secret(
&self,
group_id: &crate::GroupId,
) -> Result<group_types::GroupExporterSecret, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
self.derive_exporter_secret_for_group(group_id, &group, "nostr", b"nostr")
}
pub(crate) fn exporter_secret(
&self,
group_id: &crate::GroupId,
) -> Result<group_types::GroupExporterSecret, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let stored_secret = self
.storage()
.get_group_exporter_secret(group_id, group.epoch().as_u64())
.map_err(|e| Error::Group(e.to_string()))?;
let group_exporter_secret =
self.derive_exporter_secret_for_group(group_id, &group, "marmot", b"group-event")?;
let secret_changed = stored_secret
.as_ref()
.map(|s| s.secret != group_exporter_secret.secret)
.unwrap_or(true);
if secret_changed {
self.storage()
.save_group_exporter_secret(group_exporter_secret.clone())
.map_err(|e| Error::Group(e.to_string()))?;
if let Some(stored_secret) = stored_secret
&& let Err(e) = self
.storage()
.save_group_legacy_exporter_secret(stored_secret)
{
tracing::warn!(
target: "mdk_core::groups::exporter_secret",
"Failed to preserve legacy exporter secret for compatibility: {}",
e
);
}
}
Ok(group_exporter_secret)
}
#[cfg(feature = "mip04")]
pub(crate) fn mip04_exporter_secret(
&self,
group_id: &crate::GroupId,
) -> Result<group_types::GroupExporterSecret, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
self.derive_exporter_secret_for_group(group_id, &group, "marmot", b"encrypted-media")
}
pub fn get_group(&self, group_id: &GroupId) -> Result<Option<group_types::Group>, Error> {
self.storage()
.find_group_by_mls_group_id(group_id)
.map_err(|e| Error::Group(e.to_string()))
}
pub fn get_groups(&self) -> Result<Vec<group_types::Group>, Error> {
self.storage()
.all_groups()
.map_err(|e| Error::Group(e.to_string()))
}
pub fn groups_needing_self_update(&self, threshold_secs: u64) -> Result<Vec<GroupId>, Error> {
self.storage()
.groups_needing_self_update(threshold_secs)
.map_err(|e| Error::Group(e.to_string()))
}
pub fn get_members(&self, group_id: &GroupId) -> Result<BTreeSet<PublicKey>, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
self.live_member_identities(&group)
}
pub fn group_required_proposals(
&self,
group_id: &GroupId,
) -> Result<BTreeSet<ProposalType>, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
Ok(match group.extensions().required_capabilities() {
Some(rc) => rc.proposal_types().iter().copied().collect(),
None => BTreeSet::new(),
})
}
pub fn group_member_capabilities(
&self,
group_id: &GroupId,
) -> Result<Vec<MemberCapabilities>, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let group_data = NostrGroupDataExtension::from_group(&group)?;
let capabilities_by_leaf = exported_leaf_capabilities(&group)?;
group
.members()
.map(|member| {
let public_key = self.pubkey_for_member(&member)?;
let capabilities = capabilities_by_leaf
.get(&member.index)
.ok_or_else(|| missing_leaf_capabilities_error(member.index.u32()))?;
Ok(MemberCapabilities {
is_admin: group_data.admins.contains(&public_key),
member: public_key,
proposals: capabilities.proposals().iter().copied().collect(),
extensions: capabilities.extensions().iter().copied().collect(),
ciphersuites: capabilities.ciphersuites().to_vec(),
})
})
.collect()
}
fn leaves_missing_proposal(
&self,
group: &MlsGroup,
proposal: ProposalType,
) -> Result<Vec<PublicKey>, Error> {
let capabilities_by_leaf = exported_leaf_capabilities(group)?;
group
.members()
.try_fold(Vec::new(), |mut blockers, member| {
let capabilities = capabilities_by_leaf
.get(&member.index)
.ok_or_else(|| missing_leaf_capabilities_error(member.index.u32()))?;
if !capabilities.proposals().contains(&proposal) {
blockers.push(self.pubkey_for_member(&member)?);
}
Ok(blockers)
})
}
pub fn group_capability_upgrade_status(
&self,
group_id: &GroupId,
) -> Result<CapabilityUpgradeStatus, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let currently_required: BTreeSet<ProposalType> = group
.extensions()
.required_capabilities()
.map(|rc| rc.proposal_types().iter().copied().collect())
.unwrap_or_default();
let per_proposal = SUPPORTED_PROPOSALS
.iter()
.copied()
.map(|pt| {
if currently_required.contains(&pt) {
return Ok((pt, ProposalUpgradability::AlreadyRequired));
}
let blockers = self.leaves_missing_proposal(&group, pt)?;
Ok(if blockers.is_empty() {
(pt, ProposalUpgradability::Available)
} else {
(pt, ProposalUpgradability::Blocked { blockers })
})
})
.collect::<Result<Vec<_>, Error>>()?;
Ok(CapabilityUpgradeStatus { per_proposal })
}
pub fn upgrade_group_capabilities(
&self,
group_id: &GroupId,
proposals_to_add: &BTreeSet<ProposalType>,
) -> Result<UpdateGroupResult, Error> {
if proposals_to_add.is_empty() {
return Err(Error::EmptyUpgradeSet);
}
for pt in proposals_to_add {
if !SUPPORTED_PROPOSALS.contains(pt) {
return Err(Error::ProposalNotInSupportedSet(*pt));
}
}
let mut mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let own_leaf = mls_group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
if !self.is_leaf_node_admin(group_id, own_leaf)? {
return Err(Error::NotAdmin);
}
let current_required_capabilities = mls_group.extensions().required_capabilities();
let currently_required: BTreeSet<ProposalType> = current_required_capabilities
.map(|rc| rc.proposal_types().iter().copied().collect())
.unwrap_or_default();
for pt in proposals_to_add {
if currently_required.contains(pt) {
return Err(Error::ProposalAlreadyRequired(*pt));
}
let blockers = self.leaves_missing_proposal(&mls_group, *pt)?;
if !blockers.is_empty() {
return Err(Error::ProposalNotAvailableForUpgrade {
proposal: *pt,
blockers,
});
}
}
let mut new_required = currently_required;
new_required.extend(proposals_to_add.iter().copied());
let required_extension_types = current_required_capabilities
.map(|rc| rc.extension_types().to_vec())
.unwrap_or_else(|| GROUP_CONTEXT_REQUIRED_EXTENSIONS.to_vec());
let required_credential_types = current_required_capabilities
.map(|rc| rc.credential_types().to_vec())
.unwrap_or_default();
let new_required_proposals: Vec<ProposalType> = new_required.into_iter().collect();
let required_capabilities_extension =
Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
&required_extension_types,
&new_required_proposals,
&required_credential_types,
));
let mut upgraded_extensions = mls_group.extensions().clone();
upgraded_extensions.add_or_replace(required_capabilities_extension)?;
let signer: SignatureKeyPair = self.load_mls_signer(&mls_group)?;
let (commit_message, _welcome, _group_info) = mls_group.update_group_context_extensions(
&self.provider,
upgraded_extensions,
&signer,
)?;
self.ensure_mixed_wire_format(&mut mls_group);
let serialized_commit = commit_message.tls_serialize_detached()?;
let commit_event =
self.build_message_event(&mls_group.group_id().into(), serialized_commit, None)?;
self.track_processed_message(
commit_event.id,
&mls_group,
message_types::ProcessedMessageState::ProcessedCommit,
)?;
tracing::debug!(
target: "mdk_core::groups::upgrade_group_capabilities",
added = ?proposals_to_add,
"Proposed GroupContextExtensions commit adding proposal types to RequiredCapabilities"
);
Ok(UpdateGroupResult {
evolution_event: commit_event,
welcome_rumors: None,
mls_group_id: group_id.clone(),
})
}
pub fn own_leaf_index(&self, group_id: &GroupId) -> Result<u32, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
Ok(group.own_leaf_index().u32())
}
pub fn group_leaf_map(&self, group_id: &GroupId) -> Result<BTreeMap<u32, PublicKey>, Error> {
let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
group
.members()
.try_fold(BTreeMap::new(), |mut acc, member| {
let credentials: BasicCredential = BasicCredential::try_from(member.credential)?;
let identity_bytes: &[u8] = credentials.identity();
let public_key = self.parse_credential_identity(identity_bytes)?;
acc.insert(member.index.u32(), public_key);
Ok(acc)
})
}
pub fn get_ratchet_tree_info(&self, group_id: &GroupId) -> Result<RatchetTreeInfo, Error> {
let mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let ratchet_tree = mls_group.export_ratchet_tree();
let serialized_bytes = ratchet_tree.tls_serialize_detached()?;
let tree_hash = hex::encode(Sha256::digest(&serialized_bytes));
let serialized_tree = hex::encode(&serialized_bytes);
let leaf_nodes: Vec<LeafNodeInfo> = mls_group
.members()
.map(|member| {
let index = member.index.u32();
let basic_cred = BasicCredential::try_from(member.credential).map_err(|e| {
tracing::warn!(
leaf_index = index,
error = %e,
"Failed to parse credential for leaf node in ratchet tree"
);
Error::Group(format!("invalid credential at leaf index {index}: {e}"))
})?;
let credential_identity = hex::encode(basic_cred.identity());
Ok(LeafNodeInfo {
index,
encryption_key: hex::encode(&member.encryption_key),
signature_key: hex::encode(&member.signature_key),
credential_identity,
})
})
.collect::<Result<Vec<_>, Error>>()?;
Ok(RatchetTreeInfo {
tree_hash,
serialized_tree,
leaf_nodes,
})
}
pub fn pending_added_members_pubkeys(
&self,
group_id: &GroupId,
) -> Result<Vec<PublicKey>, Error> {
let mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let mut added_pubkeys = Vec::new();
for proposal in mls_group.pending_proposals() {
if let Proposal::Add(add_proposal) = proposal.proposal() {
let leaf_node = add_proposal.key_package().leaf_node();
let pubkey = self.pubkey_for_leaf_node(leaf_node)?;
added_pubkeys.push(pubkey);
}
}
Ok(added_pubkeys)
}
pub fn pending_removed_members_pubkeys(
&self,
group_id: &GroupId,
) -> Result<Vec<PublicKey>, Error> {
let mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let mut removed_pubkeys = Vec::new();
for proposal in mls_group.pending_proposals() {
if let Proposal::Remove(remove_proposal) = proposal.proposal() {
let removed_leaf_index = remove_proposal.removed();
if let Some(member) = mls_group.member_at(removed_leaf_index) {
let pubkey = self.pubkey_for_member(&member)?;
removed_pubkeys.push(pubkey);
}
}
}
Ok(removed_pubkeys)
}
pub fn pending_member_changes(
&self,
group_id: &GroupId,
) -> Result<PendingMemberChanges, Error> {
let mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let mut additions = Vec::new();
let mut removals = Vec::new();
for proposal in mls_group.pending_proposals() {
match proposal.proposal() {
Proposal::Add(add_proposal) => {
let leaf_node = add_proposal.key_package().leaf_node();
let pubkey = self.pubkey_for_leaf_node(leaf_node)?;
additions.push(pubkey);
}
Proposal::Remove(remove_proposal) => {
let removed_leaf_index = remove_proposal.removed();
if let Some(member) = mls_group.member_at(removed_leaf_index) {
let pubkey = self.pubkey_for_member(&member)?;
removals.push(pubkey);
}
}
_ => {}
}
}
Ok(PendingMemberChanges {
additions,
removals,
})
}
pub fn add_members(
&self,
group_id: &GroupId,
key_package_events: &[Event],
) -> Result<UpdateGroupResult, Error> {
let mut mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let mls_signer: SignatureKeyPair = self.load_mls_signer(&mls_group)?;
let own_leaf = mls_group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
if !self.is_leaf_node_admin(&mls_group.group_id().into(), own_leaf)? {
return Err(Error::NotAdmin);
}
let mut key_packages_vec: Vec<KeyPackage> = Vec::new();
for event in key_package_events {
let key_package: KeyPackage = self.parse_key_package(event)?;
key_packages_vec.push(key_package);
}
let (commit_message, welcome_message, _group_info) = mls_group
.add_members(&self.provider, &mls_signer, &key_packages_vec)
.map_err(|e| match e {
AddMembersError::CreateCommitError(CreateCommitError::ProposalValidationError(
ProposalValidationError::LeafNodeValidation(
LeafNodeValidationError::UnsupportedProposals,
),
)) => Error::InviteeMissingRequiredProposal,
other => Error::Group(other.to_string()),
})?;
let serialized_commit_message = commit_message
.tls_serialize_detached()
.map_err(|e| Error::Group(e.to_string()))?;
let commit_event = self.build_message_event(
&mls_group.group_id().into(),
serialized_commit_message,
None,
)?;
self.track_processed_message(
commit_event.id,
&mls_group,
message_types::ProcessedMessageState::ProcessedCommit,
)?;
let serialized_welcome_message = welcome_message
.tls_serialize_detached()
.map_err(|e| Error::Group(e.to_string()))?;
let group_relays = self
.get_relays(&mls_group.group_id().into())?
.into_iter()
.collect::<Vec<_>>();
let welcome_rumors = self.build_welcome_rumors_for_key_packages(
&mls_group,
serialized_welcome_message,
key_package_events.to_vec(),
&group_relays,
)?;
Ok(UpdateGroupResult {
evolution_event: commit_event,
welcome_rumors, mls_group_id: group_id.clone(),
})
}
pub fn remove_members(
&self,
group_id: &GroupId,
pubkeys: &[PublicKey],
) -> Result<UpdateGroupResult, Error> {
let mut mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let signer: SignatureKeyPair = self.load_mls_signer(&mls_group)?;
let own_leaf = mls_group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
if !self.is_leaf_node_admin(group_id, own_leaf)? {
return Err(Error::NotAdmin);
}
let own_pubkey = self.get_own_pubkey(&mls_group)?;
if pubkeys.contains(&own_pubkey) {
return Err(Error::Group(
"Cannot remove yourself from the group".to_string(),
));
}
let mut leaf_indices = Vec::new();
for member in mls_group.members() {
let pubkey = self.pubkey_for_member(&member)?;
if pubkeys.contains(&pubkey) {
leaf_indices.push(member.index);
}
}
if leaf_indices.is_empty() {
return Err(Error::Group(
"No matching members found to remove".to_string(),
));
}
let group_data = NostrGroupDataExtension::from_group(&mls_group)?;
let has_admin_removals = pubkeys.iter().any(|pk| group_data.admins.contains(pk));
let updated_extensions = if has_admin_removals {
let mut updated_data = group_data;
for pk in pubkeys {
updated_data.remove_admin(pk);
}
if updated_data.admins.is_empty() {
return Err(Error::Group(
"Cannot remove all admins from the group".to_string(),
));
}
let extension = Self::get_unknown_extension_from_group_data(&updated_data)?;
let mut extensions = mls_group.extensions().clone();
extensions.add_or_replace(extension)?;
Some(extensions)
} else {
None
};
let mut builder = mls_group
.commit_builder()
.propose_removals(leaf_indices.iter().cloned());
if let Some(ext) = updated_extensions {
builder = builder
.propose_group_context_extensions(ext)
.map_err(|e| Error::Group(e.to_string()))?;
}
let bundle = builder
.load_psks(self.provider.storage())
.map_err(|e| Error::Group(e.to_string()))?
.build(
self.provider.rand(),
self.provider.crypto(),
&signer,
|_| true,
)
.map_err(|e| Error::Group(e.to_string()))?
.stage_commit(&self.provider)
.map_err(|e| Error::Group(e.to_string()))?;
let welcome_option = bundle.to_welcome_msg();
let (commit_message, _, _group_info) = bundle.into_contents();
let serialized_commit_message = commit_message
.tls_serialize_detached()
.map_err(|e| Error::Group(e.to_string()))?;
let commit_event = self.build_message_event(
&mls_group.group_id().into(),
serialized_commit_message,
None,
)?;
self.track_processed_message(
commit_event.id,
&mls_group,
message_types::ProcessedMessageState::ProcessedCommit,
)?;
if welcome_option.is_some() {
return Err(Error::Group(
"Found welcomes when removing users".to_string(),
));
}
Ok(UpdateGroupResult {
evolution_event: commit_event,
welcome_rumors: None, mls_group_id: group_id.clone(),
})
}
fn update_group_data_extension(
&self,
mls_group: &mut MlsGroup,
group_id: &GroupId,
group_data: &NostrGroupDataExtension,
) -> Result<UpdateGroupResult, Error> {
let own_leaf = mls_group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
if !self.is_leaf_node_admin(group_id, own_leaf)? {
return Err(Error::NotAdmin);
}
let extension = Self::get_unknown_extension_from_group_data(group_data)?;
let mut extensions = mls_group.extensions().clone();
extensions.add_or_replace(extension)?;
let signature_keypair = self.load_mls_signer(mls_group)?;
let (message_out, _, _) = mls_group.update_group_context_extensions(
&self.provider,
extensions,
&signature_keypair,
)?;
let commit_event = self.build_message_event(
&mls_group.group_id().into(),
message_out.tls_serialize_detached()?,
None,
)?;
self.track_processed_message(
commit_event.id,
mls_group,
message_types::ProcessedMessageState::ProcessedCommit,
)?;
Ok(UpdateGroupResult {
evolution_event: commit_event,
welcome_rumors: None,
mls_group_id: group_id.clone(),
})
}
pub fn update_group_data(
&self,
group_id: &GroupId,
update: NostrGroupDataUpdate,
) -> Result<UpdateGroupResult, Error> {
let mut mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let mut group_data = NostrGroupDataExtension::from_group(&mls_group)?;
if let Some(name) = update.name {
group_data.name = name;
}
if let Some(description) = update.description {
group_data.description = description;
}
if let Some(image_hash) = update.image_hash {
group_data.image_hash = image_hash;
if image_hash.is_none() {
group_data.image_key = None;
group_data.image_nonce = None;
group_data.image_upload_key = None;
}
}
if let Some(image_key) = update.image_key {
group_data.image_key = image_key;
}
if let Some(image_nonce) = update.image_nonce {
group_data.image_nonce = image_nonce;
}
if let Some(image_upload_key) = update.image_upload_key {
group_data.image_upload_key = image_upload_key;
}
if let Some(relays) = update.relays {
group_data.relays = relays.into_iter().collect();
}
if let Some(ref admins) = update.admins {
group_data.admins = self.prune_and_validate_admin_update(group_id, admins)?;
}
if let Some(nostr_group_id) = update.nostr_group_id {
group_data.nostr_group_id = nostr_group_id;
}
self.update_group_data_extension(&mut mls_group, group_id, &group_data)
}
pub fn get_relays(&self, group_id: &GroupId) -> Result<BTreeSet<RelayUrl>, Error> {
let relays = self
.storage()
.group_relays(group_id)
.map_err(|e| Error::Group(e.to_string()))?;
Ok(relays.into_iter().map(|r| r.relay_url).collect())
}
fn get_unknown_extension_from_group_data(
group_data: &NostrGroupDataExtension,
) -> Result<Extension, Error> {
let serialized_group_data = group_data.as_raw().tls_serialize_detached()?;
Ok(Extension::Unknown(
group_data.extension_type(),
UnknownExtension(serialized_group_data),
))
}
pub fn create_group(
&self,
creator_public_key: &PublicKey,
member_key_package_events: Vec<Event>,
config: NostrGroupConfigData,
) -> Result<GroupResult, Error> {
let member_pubkeys = member_key_package_events
.clone()
.into_iter()
.map(|e| e.pubkey)
.collect::<Vec<PublicKey>>();
let admins = config.admins.clone();
self.validate_group_members(creator_public_key, &member_pubkeys, &admins)?;
let group_data = NostrGroupDataExtension::new(
config.name,
config.description,
admins,
config.relays.clone(),
config.image_hash,
config.image_key,
config.image_nonce,
None, );
let key_packages_vec: Vec<KeyPackage> = member_key_package_events
.iter()
.map(|event| self.parse_key_package(event))
.collect::<Result<_, _>>()?;
let required_proposals: Vec<ProposalType> = if key_packages_vec.is_empty() {
Vec::new()
} else {
SUPPORTED_PROPOSALS
.iter()
.copied()
.filter(|pt| {
key_packages_vec
.iter()
.all(|kp| kp.leaf_node().capabilities().proposals().contains(pt))
})
.collect()
};
let extension = Self::get_unknown_extension_from_group_data(&group_data)?;
let required_capabilities_extension =
Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
&GROUP_CONTEXT_REQUIRED_EXTENSIONS,
&required_proposals,
&[],
));
let extensions = Extensions::from_vec(vec![extension, required_capabilities_extension])?;
let capabilities = self.capabilities();
let sender_ratchet_config = SenderRatchetConfiguration::new(
self.config.out_of_order_tolerance,
self.config.maximum_forward_distance,
);
let group_config = MlsGroupCreateConfig::builder()
.ciphersuite(self.ciphersuite)
.wire_format_policy(MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY)
.use_ratchet_tree_extension(true)
.capabilities(capabilities)
.with_group_context_extensions(extensions)
.sender_ratchet_configuration(sender_ratchet_config)
.max_past_epochs(self.config.max_past_epochs)
.build();
let (credential, signer) = self.generate_credential_with_key(creator_public_key)?;
let mut mls_group =
MlsGroup::new(&self.provider, &signer, &group_config, credential.clone())?;
let welcome_rumors = if key_packages_vec.is_empty() {
Vec::new()
} else {
let (_, welcome_out, _group_info) =
mls_group.add_members(&self.provider, &signer, &key_packages_vec)?;
mls_group.merge_pending_commit(&self.provider)?;
let serialized_welcome_message = welcome_out.tls_serialize_detached()?;
self.build_welcome_rumors_for_key_packages(
&mls_group,
serialized_welcome_message,
member_key_package_events,
&config.relays,
)?
.ok_or(Error::Welcome("Error creating welcome rumors".to_string()))?
};
let group = group_types::Group {
mls_group_id: mls_group.group_id().clone().into(),
nostr_group_id: group_data.nostr_group_id,
name: group_data.name,
description: group_data.description,
admin_pubkeys: group_data.admins,
last_message_id: None,
last_message_at: None,
last_message_processed_at: None,
epoch: mls_group.epoch().as_u64(),
state: group_types::GroupState::Active,
image_hash: config.image_hash,
image_key: config.image_key.map(mdk_storage_traits::Secret::new),
image_nonce: config.image_nonce.map(mdk_storage_traits::Secret::new),
self_update_state: group_types::SelfUpdateState::CompletedAt(Timestamp::now()),
};
self.storage().save_group(group.clone()).map_err(
|e: mdk_storage_traits::groups::error::GroupError| Error::Group(e.to_string()),
)?;
self.storage()
.replace_group_relays(&group.mls_group_id, config.relays.into_iter().collect())
.map_err(|e| Error::Group(e.to_string()))?;
Ok(GroupResult {
group,
welcome_rumors,
})
}
pub fn self_update(&self, group_id: &GroupId) -> Result<UpdateGroupResult, Error> {
let mut mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
tracing::debug!(target: "mdk_core::groups::self_update", "Current epoch: {:?}", mls_group.epoch().as_u64());
let current_signer: SignatureKeyPair = self.load_mls_signer(&mls_group)?;
let own_leaf = mls_group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
let new_signature_keypair = SignatureKeyPair::new(self.ciphersuite.signature_algorithm())?;
new_signature_keypair
.store(self.provider.storage())
.map_err(|e| Error::Provider(e.to_string()))?;
let pubkey = BasicCredential::try_from(own_leaf.credential().clone())?
.identity()
.to_vec();
let new_credential: BasicCredential = BasicCredential::new(pubkey);
let new_credential_with_key = CredentialWithKey {
credential: new_credential.into(),
signature_key: new_signature_keypair.public().into(),
};
let new_signer_bundle = NewSignerBundle {
signer: &new_signature_keypair,
credential_with_key: new_credential_with_key.clone(),
};
let leaf_node_params = LeafNodeParameters::builder()
.with_credential_with_key(new_credential_with_key)
.with_capabilities(self.capabilities())
.with_extensions(own_leaf.extensions().clone())
.build();
let commit_message_bundle = mls_group.self_update_with_new_signer(
&self.provider,
¤t_signer,
new_signer_bundle,
leaf_node_params,
)?;
self.ensure_mixed_wire_format(&mut mls_group);
let serialized_commit_message = commit_message_bundle.commit().tls_serialize_detached()?;
let commit_event = self.build_message_event(
&mls_group.group_id().into(),
serialized_commit_message,
None,
)?;
self.track_processed_message(
commit_event.id,
&mls_group,
message_types::ProcessedMessageState::ProcessedCommit,
)?;
let serialized_welcome_message = commit_message_bundle
.welcome()
.map(|w| {
w.tls_serialize_detached()
.map_err(|e| Error::Group(e.to_string()))
})
.transpose()?;
if serialized_welcome_message.is_some() {
return Err(Error::Group(
"Found welcomes when performing a self update".to_string(),
));
}
Ok(UpdateGroupResult {
evolution_event: commit_event,
welcome_rumors: None, mls_group_id: group_id.clone(),
})
}
fn ensure_mixed_wire_format(&self, mls_group: &mut MlsGroup) {
if mls_group.configuration().wire_format_policy() == MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY {
return;
}
let sender_ratchet_config = SenderRatchetConfiguration::new(
self.config.out_of_order_tolerance,
self.config.maximum_forward_distance,
);
let converged_config = MlsGroupJoinConfig::builder()
.wire_format_policy(MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY)
.use_ratchet_tree_extension(true)
.sender_ratchet_configuration(sender_ratchet_config)
.max_past_epochs(self.config.max_past_epochs)
.build();
tracing::debug!(
target: "mdk_core::groups::ensure_mixed_wire_format",
"Converging local wire-format policy to MIXED_CIPHERTEXT"
);
if let Err(e) = mls_group.set_configuration(self.storage(), &converged_config) {
tracing::warn!(
target: "mdk_core::groups::ensure_mixed_wire_format",
"Failed to persist converged wire-format policy: {e}. \
In-memory config is correct for this session; \
persisted config may be stale."
);
}
}
fn try_self_remove(
&self,
group: &mut MlsGroup,
signer: &SignatureKeyPair,
) -> Result<MlsMessageOut, Error> {
let self_remove_required = group
.extensions()
.required_capabilities()
.is_some_and(|rc| rc.proposal_types().contains(&ProposalType::SelfRemove));
if !self_remove_required {
tracing::debug!(
target: "mdk_core::groups::leave_group",
"SelfRemove not in group's RequiredCapabilities, \
falling back to Remove proposal"
);
return group
.leave_group(&self.provider, signer)
.map_err(|e| Error::Group(e.to_string()));
}
let sender_ratchet_config = SenderRatchetConfiguration::new(
self.config.out_of_order_tolerance,
self.config.maximum_forward_distance,
);
let plaintext_config = MlsGroupJoinConfig::builder()
.wire_format_policy(MIXED_PLAINTEXT_WIRE_FORMAT_POLICY)
.use_ratchet_tree_extension(true)
.sender_ratchet_configuration(sender_ratchet_config)
.max_past_epochs(self.config.max_past_epochs)
.build();
let ciphertext_config = MlsGroupJoinConfig::builder()
.wire_format_policy(MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY)
.use_ratchet_tree_extension(true)
.sender_ratchet_configuration(sender_ratchet_config)
.max_past_epochs(self.config.max_past_epochs)
.build();
group
.set_configuration(self.storage(), &plaintext_config)
.map_err(|e| Error::Group(format!("Failed to switch wire format: {e}")))?;
let result = group.leave_group_via_self_remove(&self.provider, signer);
if let Err(e) = group.set_configuration(self.storage(), &ciphertext_config) {
tracing::error!(
target: "mdk_core::groups::leave_group",
"Failed to persist restored ciphertext wire format: {e}. \
In-memory config is correct; persisted config may be stale."
);
}
result.map_err(|e| Error::Group(e.to_string()))
}
pub fn self_demote(&self, group_id: &GroupId) -> Result<UpdateGroupResult, Error> {
let mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let own_leaf = mls_group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
let own_pubkey = self.pubkey_for_leaf_node(own_leaf)?;
let group_data = NostrGroupDataExtension::from_group(&mls_group)?;
if !group_data.admins.contains(&own_pubkey) {
return Err(Error::NotAdmin);
}
let active_admins: Vec<_> = group_data
.admins
.into_iter()
.filter(|pk| {
mls_group.members().any(|member| {
BasicCredential::try_from(member.credential)
.ok()
.and_then(|cred| self.parse_credential_identity(cred.identity()).ok())
.is_some_and(|member_pk| &member_pk == pk)
})
})
.collect();
if active_admins.len() <= 1 {
return Err(Error::Group(
"Cannot self-demote: last active admin. \
Designate another admin first using update_group_data."
.to_string(),
));
}
let new_admins: Vec<_> = active_admins
.into_iter()
.filter(|pk| pk != &own_pubkey)
.collect();
self.update_group_data(group_id, NostrGroupDataUpdate::new().admins(new_admins))
}
pub fn leave_group(&self, group_id: &GroupId) -> Result<UpdateGroupResult, Error> {
let mut group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let own_leaf = group.own_leaf().ok_or(Error::OwnLeafNotFound)?;
if self.is_leaf_node_admin(group_id, own_leaf)? {
return Err(Error::Group(
"Admins must self-demote before leaving. \
Use self_demote() first."
.to_string(),
));
}
let signer: SignatureKeyPair = self.load_mls_signer(&group)?;
let leave_message = self.try_self_remove(&mut group, &signer)?;
let serialized_message_out = leave_message
.tls_serialize_detached()
.map_err(|e| Error::Group(e.to_string()))?;
let evolution_event =
self.build_message_event(&group.group_id().into(), serialized_message_out, None)?;
self.track_processed_message(
evolution_event.id,
&group,
message_types::ProcessedMessageState::Processed,
)?;
Ok(UpdateGroupResult {
evolution_event,
welcome_rumors: None,
mls_group_id: group_id.clone(),
})
}
pub fn delete_messages_for_group(&self, group_id: &GroupId) -> Result<usize, Error> {
self.storage()
.delete_messages_for_group(group_id)
.map_err(|e| Error::Group(e.to_string()))
}
pub fn delete_group(&self, group_id: &GroupId) -> Result<(), Error> {
self.storage().delete_group(group_id)?;
self.epoch_snapshots.remove_group(group_id);
Ok(())
}
pub fn clear_pending_commit(&self, group_id: &GroupId) -> Result<(), Error> {
let mut mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let self_update_new_pubkey: Option<Vec<u8>> =
mls_group.pending_commit().and_then(|staged_commit| {
let has_update_signal = staged_commit.update_path_leaf_node().is_some()
|| staged_commit.update_proposals().next().is_some();
let no_non_update_proposals = staged_commit
.queued_proposals()
.all(|p| matches!(p.proposal(), Proposal::Update(_)));
if has_update_signal && no_non_update_proposals {
staged_commit
.update_path_leaf_node()
.map(|leaf| leaf.signature_key().as_slice().to_vec())
} else {
None
}
});
mls_group
.clear_pending_commit(self.provider.storage())
.map_err(|e| Error::Provider(e.to_string()))?;
if let Some(pubkey_bytes) = self_update_new_pubkey {
SignatureKeyPair::delete(
self.provider.storage(),
&pubkey_bytes,
self.ciphersuite.signature_algorithm(),
)
.map_err(|e| Error::Provider(e.to_string()))?;
}
Ok(())
}
pub fn merge_pending_commit(&self, group_id: &GroupId) -> Result<(), Error> {
let mut mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let is_self_update = mls_group.pending_commit().is_some_and(|staged_commit| {
let has_update_signal = staged_commit.update_path_leaf_node().is_some()
|| staged_commit.update_proposals().next().is_some();
let no_non_update_proposals = staged_commit
.queued_proposals()
.all(|p| matches!(p.proposal(), Proposal::Update(_)));
has_update_signal && no_non_update_proposals
});
mls_group.merge_pending_commit(&self.provider)?;
self.exporter_secret(group_id)?;
#[cfg(feature = "mip04")]
{
let mip04_secret = self.mip04_exporter_secret(group_id)?;
self.storage()
.save_group_mip04_exporter_secret(mip04_secret)
.map_err(|_| Error::Group("Failed to save MIP-04 exporter secret".to_string()))?;
}
let min_epoch_to_keep = mls_group
.epoch()
.as_u64()
.saturating_sub(self.config.max_past_epochs as u64);
self.storage()
.prune_group_exporter_secrets_before_epoch(group_id, min_epoch_to_keep)
.map_err(|_| Error::Group("Failed to prune exporter secrets".to_string()))?;
self.sync_group_metadata_from_mls(group_id)?;
if is_self_update {
let mut group = self.get_group(group_id)?.ok_or(Error::GroupNotFound)?;
group.self_update_state = group_types::SelfUpdateState::CompletedAt(Timestamp::now());
self.storage()
.save_group(group)
.map_err(|e| Error::Group(e.to_string()))?;
}
Ok(())
}
pub fn sync_group_metadata_from_mls(&self, group_id: &GroupId) -> Result<(), Error> {
let mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
let mut stored_group = self.get_group(group_id)?.ok_or(Error::GroupNotFound)?;
let group_data = NostrGroupDataExtension::from_group(&mls_group)?;
self.validate_active_admins_in_group(&mls_group, &group_data.admins)?;
stored_group.epoch = mls_group.epoch().as_u64();
stored_group.name = group_data.name;
stored_group.description = group_data.description;
stored_group.image_hash = group_data.image_hash;
stored_group.image_key = group_data.image_key.map(mdk_storage_traits::Secret::new);
stored_group.image_nonce = group_data.image_nonce.map(mdk_storage_traits::Secret::new);
stored_group.admin_pubkeys = group_data.admins;
stored_group.nostr_group_id = group_data.nostr_group_id;
self.storage()
.replace_group_relays(group_id, group_data.relays)
.map_err(|e| Error::Group(e.to_string()))?;
self.storage()
.save_group(stored_group)
.map_err(|e| Error::Group(e.to_string()))?;
Ok(())
}
fn validate_group_members(
&self,
creator_pubkey: &PublicKey,
member_pubkeys: &[PublicKey],
admin_pubkeys: &[PublicKey],
) -> Result<bool, Error> {
if !admin_pubkeys.contains(creator_pubkey) {
return Err(Error::Group("Creator must be an admin".to_string()));
}
if member_pubkeys.contains(creator_pubkey) {
return Err(Error::Group(
"Creator must not be included as a member".to_string(),
));
}
for pubkey in admin_pubkeys.iter() {
if !member_pubkeys.contains(pubkey) && creator_pubkey != pubkey {
return Err(Error::Group("Admin must be a member".to_string()));
}
}
Ok(true)
}
fn prune_and_validate_admin_update(
&self,
group_id: &GroupId,
new_admins: &[PublicKey],
) -> Result<BTreeSet<PublicKey>, Error> {
let current_members = self.get_members(group_id)?;
let valid_admins: BTreeSet<PublicKey> = new_admins
.iter()
.filter(|admin| current_members.contains(admin))
.copied()
.collect();
if valid_admins.is_empty() {
return Err(Error::UpdateGroupContextExts(
"Admin set cannot be empty".to_string(),
));
}
Ok(valid_admins)
}
fn track_processed_message(
&self,
event_id: EventId,
mls_group: &MlsGroup,
state: message_types::ProcessedMessageState,
) -> Result<(), Error> {
let processed_message = message_types::ProcessedMessage {
wrapper_event_id: event_id,
message_event_id: None,
processed_at: Timestamp::now(),
epoch: Some(mls_group.epoch().as_u64()),
mls_group_id: Some(mls_group.group_id().into()),
state,
failure_reason: None,
};
self.storage()
.save_processed_message(processed_message)
.map_err(|e| Error::Message(e.to_string()))
}
pub(crate) fn build_message_event(
&self,
group_id: &GroupId,
serialized_content: Vec<u8>,
tags: Option<Vec<EventTag>>,
) -> Result<Event, Error> {
let group = self.get_group(group_id)?.ok_or(Error::GroupNotFound)?;
let secret: group_types::GroupExporterSecret = self.exporter_secret(group_id)?;
let encrypted_content = encrypt_message_with_exporter_secret(&secret, &serialized_content)?;
let ephemeral_nostr_keys: Keys = Keys::generate();
let tag: Tag = Tag::custom(TagKind::h(), [hex::encode(group.nostr_group_id)]);
let encoding_tag: Tag = Tag::custom(TagKind::Custom("encoding".into()), ["base64"]);
let mut builder = EventBuilder::new(Kind::MlsGroupMessage, encrypted_content)
.tag(tag)
.tag(encoding_tag);
if let Some(tags) = tags {
builder = builder.tags(tags.into_iter().map(|t| t.into_tag()));
}
let event = builder.sign_with_keys(&ephemeral_nostr_keys)?;
Ok(event)
}
pub(crate) fn build_welcome_rumors_for_key_packages(
&self,
group: &MlsGroup,
serialized_welcome: Vec<u8>,
key_package_events: Vec<Event>,
group_relays: &[RelayUrl],
) -> Result<Option<Vec<UnsignedEvent>>, Error> {
let committer_pubkey = self.get_own_pubkey(group)?;
let mut welcome_rumors_vec = Vec::new();
for event in key_package_events {
let encoding = ContentEncoding::Base64;
let encoded_welcome = encode_content(&serialized_welcome, encoding);
tracing::debug!(
target: "mdk_core::groups",
"Encoded welcome using {} format",
encoding.as_tag_value()
);
let tags = vec![
Tag::from_standardized(TagStandard::Relays(group_relays.to_vec())),
Tag::event(event.id),
Tag::client(format!("MDK/{}", env!("CARGO_PKG_VERSION"))),
Tag::custom(
TagKind::Custom("encoding".into()),
[encoding.as_tag_value()],
),
];
let welcome_rumor = EventBuilder::new(Kind::MlsWelcome, encoded_welcome)
.tags(tags)
.build(committer_pubkey);
welcome_rumors_vec.push(welcome_rumor);
}
let welcome_rumors = if !welcome_rumors_vec.is_empty() {
Some(welcome_rumors_vec)
} else {
None
};
Ok(welcome_rumors)
}
}
fn missing_leaf_capabilities_error(leaf_index: u32) -> Error {
Error::Group(format!("missing capabilities for leaf index {leaf_index}"))
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::iter::once;
use mdk_memory_storage::MdkMemoryStorage;
use mdk_storage_traits::groups::GroupStorage;
use mdk_storage_traits::messages::{MessageStorage, types as message_types};
use nostr::{Keys, PublicKey};
use openmls::prelude::{BasicCredential, ProposalType};
use openmls_basic_credential::SignatureKeyPair;
use super::NostrGroupDataExtension;
use crate::Error;
use crate::constant::NOSTR_GROUP_DATA_EXTENSION_TYPE;
use crate::groups::{NostrGroupDataUpdate, ProposalUpgradability};
use crate::messages::MessageProcessingResult;
use crate::test_util::*;
use crate::tests::create_test_mdk;
#[test]
fn test_validate_group_members() {
let mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let member_pks: Vec<PublicKey> = members.iter().map(|k| k.public_key()).collect();
assert!(
mdk.validate_group_members(&creator_pk, &member_pks, &admins)
.is_ok()
);
let bad_admins = vec![member_pks[0]];
assert!(
mdk.validate_group_members(&creator_pk, &member_pks, &bad_admins)
.is_err()
);
let bad_members = vec![creator_pk, member_pks[0]];
assert!(
mdk.validate_group_members(&creator_pk, &bad_members, &admins)
.is_err()
);
let non_member = Keys::generate().public_key();
let bad_admins = vec![creator_pk, non_member];
assert!(
mdk.validate_group_members(&creator_pk, &member_pks, &bad_admins)
.is_err()
);
}
#[test]
fn test_create_group_basic() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let members = creator_mdk
.get_members(group_id)
.expect("Failed to get members");
assert_eq!(members.len(), 3); assert!(members.contains(&creator_pk));
for member_keys in &initial_members {
assert!(members.contains(&member_keys.public_key()));
}
}
#[test]
fn test_create_single_member_group() {
let creator_mdk = create_test_mdk();
let creator = Keys::generate();
let creator_pk = creator.public_key();
let create_result = creator_mdk
.create_group(
&creator_pk,
Vec::new(), create_nostr_group_config_data(vec![creator_pk]),
)
.expect("Failed to create single-member group");
let group_id = &create_result.group.mls_group_id;
assert!(
create_result.welcome_rumors.is_empty(),
"Single-member group should have no welcome rumors"
);
let members = creator_mdk
.get_members(group_id)
.expect("Failed to get members");
assert_eq!(
members.len(),
1,
"Single-member group should have exactly 1 member"
);
assert!(
members.contains(&creator_pk),
"Creator should be in the group"
);
let group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert_eq!(group.name, "Test Group");
assert!(group.admin_pubkeys.contains(&creator_pk));
}
#[test]
fn test_get_members() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let members = creator_mdk
.get_members(group_id)
.expect("Failed to get members");
assert_eq!(members.len(), 3); assert!(members.contains(&creator_pk));
for member_keys in &initial_members {
assert!(members.contains(&member_keys.public_key()));
}
}
#[test]
fn test_add_members_epoch_advancement() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let initial_group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
let initial_epoch = initial_group.epoch;
let new_member = Keys::generate();
let new_key_package_event = create_key_package_event(&creator_mdk, &new_member);
let _add_result = creator_mdk
.add_members(group_id, &[new_key_package_event])
.expect("Failed to add member");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for member addition");
let mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let final_mls_epoch = mls_group.epoch().as_u64();
assert!(
final_mls_epoch > initial_epoch,
"MLS group epoch should advance after adding members (initial: {}, final: {})",
initial_epoch,
final_mls_epoch
);
let final_members = creator_mdk
.get_members(group_id)
.expect("Failed to get members");
assert!(
final_members.contains(&new_member.public_key()),
"New member should be in the group"
);
assert_eq!(
final_members.len(),
4, "Should have 4 total members"
);
}
#[test]
fn test_get_own_pubkey() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let own_pubkey = creator_mdk
.get_own_pubkey(&mls_group)
.expect("Failed to get own pubkey");
assert_eq!(
own_pubkey, creator_pk,
"Own pubkey should match creator pubkey"
);
}
#[test]
fn test_admin_check() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let stored_group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert!(
stored_group.admin_pubkeys.contains(&creator_pk),
"Creator should be admin"
);
}
#[test]
fn test_admin_permission_checks() {
let admin_mdk = create_test_mdk();
let non_admin_mdk = create_test_mdk();
let admin_keys = Keys::generate();
let non_admin_keys = Keys::generate();
let member1_keys = Keys::generate();
let admin_pk = admin_keys.public_key();
let _non_admin_pk = non_admin_keys.public_key();
let member1_pk = member1_keys.public_key();
let non_admin_event = create_key_package_event(&admin_mdk, &non_admin_keys);
let member1_event = create_key_package_event(&admin_mdk, &member1_keys);
let create_result = admin_mdk
.create_group(
&admin_pk,
vec![non_admin_event.clone(), member1_event.clone()],
create_nostr_group_config_data(vec![admin_pk]), )
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
admin_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let new_member_keys = Keys::generate();
let _new_member_pk = new_member_keys.public_key();
let new_member_event = create_key_package_event(&non_admin_mdk, &new_member_keys);
let add_result = admin_mdk.add_members(group_id, &[new_member_event]);
assert!(add_result.is_ok(), "Admin should be able to add members");
admin_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for member addition");
let remove_result = admin_mdk.remove_members(group_id, &[member1_pk]);
assert!(
remove_result.is_ok(),
"Admin should be able to remove members"
);
}
#[test]
fn test_admin_check_uses_mls_state_not_stale_storage() {
let creator_mdk = create_test_mdk();
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let alice_pk = alice_keys.public_key();
let bob_pk = bob_keys.public_key();
let _charlie_pk = charlie_keys.public_key();
let bob_event = create_key_package_event(&creator_mdk, &bob_keys);
let charlie_event = create_key_package_event(&creator_mdk, &charlie_keys);
let create_result = creator_mdk
.create_group(
&alice_pk,
vec![bob_event, charlie_event],
create_nostr_group_config_data(vec![alice_pk]), )
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id;
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let alice_leaf = mls_group.own_leaf().expect("Group should have own leaf");
assert!(
creator_mdk
.is_leaf_node_admin(&group_id.clone(), alice_leaf)
.unwrap(),
"Alice should be admin in MLS state"
);
let mut stored_group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
stored_group.admin_pubkeys.insert(bob_pk);
stored_group.admin_pubkeys.remove(&alice_pk);
creator_mdk
.storage()
.save_group(stored_group.clone())
.expect("Failed to save modified group");
let stale_stored_group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert!(
stale_stored_group.admin_pubkeys.contains(&bob_pk),
"Stale storage should have Bob as admin"
);
assert!(
!stale_stored_group.admin_pubkeys.contains(&alice_pk),
"Stale storage should NOT have Alice as admin"
);
assert!(
creator_mdk
.is_leaf_node_admin(&group_id.clone(), alice_leaf)
.unwrap(),
"is_leaf_node_admin should use MLS state, not stale storage"
);
}
#[test]
fn test_pubkey_for_member() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let members: Vec<_> = mls_group.members().collect();
let mut found_pubkeys = Vec::new();
for member in &members {
let pubkey = creator_mdk
.pubkey_for_member(member)
.expect("Failed to get pubkey for member");
found_pubkeys.push(pubkey);
}
assert!(
found_pubkeys.contains(&creator_pk),
"Should find creator pubkey"
);
for member_keys in &initial_members {
assert!(
found_pubkeys.contains(&member_keys.public_key()),
"Should find member pubkey: {:?}",
member_keys.public_key()
);
}
assert_eq!(found_pubkeys.len(), 3, "Should have 3 members total");
}
#[test]
fn test_remove_members_group_not_found() {
let mdk = create_test_mdk();
let non_existent_group_id = crate::GroupId::from_slice(&[1, 2, 3, 4, 5]);
let dummy_pubkey = Keys::generate().public_key();
let result = mdk.remove_members(&non_existent_group_id, &[dummy_pubkey]);
assert!(
matches!(result, Err(crate::Error::GroupNotFound)),
"Should return GroupNotFound error for non-existent group"
);
}
#[test]
fn test_remove_members_no_matching_members() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let non_member = Keys::generate().public_key();
let result = creator_mdk.remove_members(group_id, &[non_member]);
assert!(
matches!(
result,
Err(crate::Error::Group(ref msg)) if msg.contains("No matching members found")
),
"Should return error when no matching members found"
);
}
#[test]
fn test_remove_members_epoch_advancement() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let initial_group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
let initial_epoch = initial_group.epoch;
let member_to_remove = initial_members[0].public_key();
let _remove_result = creator_mdk
.remove_members(group_id, &[member_to_remove])
.expect("Failed to remove member");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for member removal");
let mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let final_mls_epoch = mls_group.epoch().as_u64();
assert!(
final_mls_epoch > initial_epoch,
"MLS group epoch should advance after removing members (initial: {}, final: {})",
initial_epoch,
final_mls_epoch
);
let final_members = creator_mdk
.get_members(group_id)
.expect("Failed to get members");
assert!(
!final_members.contains(&member_to_remove),
"Removed member should not be in the group"
);
assert_eq!(
final_members.len(),
2, "Should have 2 total members after removal"
);
}
#[test]
fn test_remove_members_strips_admin_status() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let member1_pk = initial_members[0].public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let group_before = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert!(
group_before.admin_pubkeys.contains(&member1_pk),
"member1 should be admin before removal"
);
creator_mdk
.remove_members(group_id, &[member1_pk])
.expect("Failed to remove member");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let group_after = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
let expected_admins: BTreeSet<PublicKey> = once(creator_pk).collect();
assert_eq!(
group_after.admin_pubkeys, expected_admins,
"Admin set should contain only the creator after removing admin member"
);
let re_add_kp = create_key_package_event(&creator_mdk, &initial_members[0]);
creator_mdk
.add_members(group_id, &[re_add_kp])
.expect("Failed to re-add member");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit after re-add");
let group_readded = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert_eq!(
group_readded.admin_pubkeys, expected_admins,
"Re-added member should NOT regain admin status"
);
assert!(
creator_mdk
.get_members(group_id)
.expect("Failed to get members")
.contains(&member1_pk),
"member1 should be back in the group"
);
}
#[test]
fn test_remove_non_admin_member_preserves_admin_list() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, _) = create_test_group_members();
let creator_pk = creator.public_key();
let member1_pk = initial_members[0].public_key();
let member2_pk = initial_members[1].public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(vec![creator_pk]),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let admin_list_before = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist")
.admin_pubkeys
.clone();
creator_mdk
.remove_members(group_id, &[member2_pk])
.expect("Failed to remove member");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let group_after = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert_eq!(
group_after.admin_pubkeys, admin_list_before,
"Admin list should be unchanged when removing a non-admin member"
);
let members = creator_mdk
.get_members(group_id)
.expect("Failed to get members");
assert!(
!members.contains(&member2_pk),
"member2 should have been removed from the group"
);
assert!(
members.contains(&member1_pk),
"member1 should still be in the group"
);
}
#[test]
fn test_remove_multiple_admins_strips_all() {
let creator_mdk = create_test_mdk();
let creator = Keys::generate();
let creator_pk = creator.public_key();
let member1 = Keys::generate();
let member1_pk = member1.public_key();
let member2 = Keys::generate();
let member2_pk = member2.public_key();
let member3 = Keys::generate();
let members = vec![&member1, &member2, &member3];
let mut key_package_events = Vec::new();
for m in &members {
key_package_events.push(create_key_package_event(&creator_mdk, m));
}
let create_result = creator_mdk
.create_group(
&creator_pk,
key_package_events,
create_nostr_group_config_data(vec![creator_pk, member1_pk, member2_pk]),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
creator_mdk
.remove_members(group_id, &[member1_pk, member2_pk])
.expect("Failed to remove members");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let group_after = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
let expected_admins: BTreeSet<PublicKey> = once(creator_pk).collect();
assert_eq!(
group_after.admin_pubkeys, expected_admins,
"Only creator should remain as admin after bulk admin removal"
);
}
#[test]
fn test_remove_members_rejects_self_removal() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut key_package_events = Vec::new();
for m in &initial_members {
key_package_events.push(create_key_package_event(&creator_mdk, m));
}
let create_result = creator_mdk
.create_group(
&creator_pk,
key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let result = creator_mdk.remove_members(group_id, &[creator_pk]);
assert!(result.is_err(), "Self-removal should be rejected");
assert!(
result
.unwrap_err()
.to_string()
.contains("Cannot remove yourself"),
"Error should mention self-removal"
);
}
#[test]
fn test_leave_group_records_processed_state() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut key_package_events = Vec::new();
for m in &initial_members {
key_package_events.push(create_key_package_event(&creator_mdk, m));
}
let create_result = creator_mdk
.create_group(
&creator_pk,
key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
creator_mdk
.self_demote(group_id)
.expect("Failed to self-demote");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge self-demote commit");
let leave_result = creator_mdk
.leave_group(group_id)
.expect("Failed to leave group");
let processed = creator_mdk
.storage()
.find_processed_message_by_event_id(&leave_result.evolution_event.id)
.expect("Failed to query processed message")
.expect("ProcessedMessage should exist");
assert_eq!(
processed.state,
message_types::ProcessedMessageState::Processed,
"leave_group should record Processed state, not ProcessedCommit"
);
assert_eq!(processed.wrapper_event_id, leave_result.evolution_event.id);
assert!(processed.failure_reason.is_none());
}
#[test]
fn test_self_update_success() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let initial_members_set = creator_mdk
.get_members(group_id)
.expect("Failed to get initial members");
assert_eq!(initial_members_set.len(), 3);
let initial_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let initial_epoch = initial_mls_group.epoch().as_u64();
let update_result = creator_mdk
.self_update(group_id)
.expect("Failed to perform self update");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for self update");
assert!(
!update_result.evolution_event.content.is_empty(),
"Evolution event should not be empty"
);
let final_members = creator_mdk
.get_members(group_id)
.expect("Failed to get final members");
assert_eq!(
final_members.len(),
3,
"Member count should remain the same after self update"
);
assert!(
final_members.contains(&creator_pk),
"Creator should still be in group"
);
for initial_member_keys in &initial_members {
assert!(
final_members.contains(&initial_member_keys.public_key()),
"Initial member should still be in group"
);
}
let final_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let final_epoch = final_mls_group.epoch().as_u64();
assert!(
final_epoch > initial_epoch,
"Epoch should advance after self update (initial: {}, final: {})",
initial_epoch,
final_epoch
);
}
#[test]
fn test_self_update_group_not_found() {
let mdk = create_test_mdk();
let non_existent_group_id = crate::GroupId::from_slice(&[1, 2, 3, 4, 5]);
let result = mdk.self_update(&non_existent_group_id);
assert!(
matches!(result, Err(crate::Error::GroupNotFound)),
"Should return GroupNotFound error for non-existent group"
);
}
#[test]
fn test_self_update_key_rotation() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let initial_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let initial_own_leaf = initial_mls_group
.own_leaf()
.expect("Failed to get initial own leaf");
let initial_signature_key = initial_own_leaf.signature_key().as_slice().to_vec();
let _update_result = creator_mdk
.self_update(group_id)
.expect("Failed to perform self update");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for self update");
let final_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let final_own_leaf = final_mls_group
.own_leaf()
.expect("Failed to get final own leaf");
let final_signature_key = final_own_leaf.signature_key().as_slice().to_vec();
assert_ne!(
initial_signature_key, final_signature_key,
"Signature key should be different after self update"
);
let initial_credential = BasicCredential::try_from(initial_own_leaf.credential().clone())
.expect("Failed to extract initial credential");
let final_credential = BasicCredential::try_from(final_own_leaf.credential().clone())
.expect("Failed to extract final credential");
assert_eq!(
initial_credential.identity(),
final_credential.identity(),
"Public key identity should remain the same after self update"
);
}
#[test]
fn test_self_update_exporter_secret_rotation() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let initial_secret = creator_mdk
.exporter_secret(group_id)
.expect("Failed to get initial exporter secret");
let _update_result = creator_mdk
.self_update(group_id)
.expect("Failed to perform self update");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for self update");
let final_secret = creator_mdk
.exporter_secret(group_id)
.expect("Failed to get final exporter secret");
assert_ne!(
initial_secret.secret, final_secret.secret,
"Exporter secret should be different after self update"
);
assert!(
final_secret.epoch > initial_secret.epoch,
"Epoch should advance after self update (initial: {}, final: {})",
initial_secret.epoch,
final_secret.epoch
);
assert_eq!(
initial_secret.mls_group_id, final_secret.mls_group_id,
"Group ID should remain the same"
);
}
#[test]
fn test_exporter_secret_rederives_current_epoch_instead_of_trusting_storage() {
let mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&mdk, &creator, &members, &admins);
let legacy_secret = mdk
.legacy_exporter_secret(&group_id)
.expect("Failed to derive legacy exporter secret");
mdk.storage()
.save_group_exporter_secret(legacy_secret.clone())
.expect("Failed to persist legacy exporter secret");
let refreshed_secret = mdk
.exporter_secret(&group_id)
.expect("Failed to derive refreshed exporter secret");
assert_ne!(
refreshed_secret.secret, legacy_secret.secret,
"Current exporter secret should ignore stale stored bytes"
);
let stored_secret = mdk
.storage()
.get_group_exporter_secret(&group_id, refreshed_secret.epoch)
.expect("Failed to load stored exporter secret")
.expect("Stored exporter secret should exist");
assert_eq!(
stored_secret.secret, refreshed_secret.secret,
"Storage should be healed to the freshly derived exporter secret"
);
}
#[test]
fn test_update_group_data() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let initial_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let initial_group_data = NostrGroupDataExtension::from_group(&initial_mls_group).unwrap();
let new_name = "Updated Name".to_string();
let update = NostrGroupDataUpdate::new().name(new_name.clone());
let update_result = creator_mdk
.update_group_data(group_id, update)
.expect("Failed to update group name");
assert!(!update_result.evolution_event.content.is_empty());
assert!(update_result.welcome_rumors.is_none());
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let updated_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let updated_group_data = NostrGroupDataExtension::from_group(&updated_mls_group).unwrap();
assert_eq!(updated_group_data.name, new_name);
assert_eq!(
updated_group_data.description,
initial_group_data.description
);
assert_eq!(updated_group_data.image_hash, initial_group_data.image_hash);
let new_description = "Updated Description".to_string();
let new_image_hash =
mdk_storage_traits::test_utils::crypto_utils::generate_random_bytes(32)
.try_into()
.unwrap();
let new_image_key = mdk_storage_traits::test_utils::crypto_utils::generate_random_bytes(32)
.try_into()
.unwrap();
let new_image_upload_key =
mdk_storage_traits::test_utils::crypto_utils::generate_random_bytes(32)
.try_into()
.unwrap();
let update = NostrGroupDataUpdate::new()
.description(new_description.clone())
.image_hash(Some(new_image_hash))
.image_key(Some(new_image_key))
.image_upload_key(Some(new_image_upload_key));
let update_result = creator_mdk
.update_group_data(group_id, update)
.expect("Failed to update multiple fields");
assert!(!update_result.evolution_event.content.is_empty());
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let final_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let final_group_data = NostrGroupDataExtension::from_group(&final_mls_group).unwrap();
assert_eq!(final_group_data.name, new_name); assert_eq!(final_group_data.description, new_description);
assert_eq!(final_group_data.image_hash, Some(new_image_hash));
assert_eq!(final_group_data.image_key, Some(new_image_key));
assert_eq!(
final_group_data.image_upload_key,
Some(new_image_upload_key)
);
let update = NostrGroupDataUpdate::new().image_hash(None);
let update_result = creator_mdk
.update_group_data(group_id, update)
.expect("Failed to clear optional fields");
assert!(!update_result.evolution_event.content.is_empty());
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let cleared_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let cleared_group_data = NostrGroupDataExtension::from_group(&cleared_mls_group).unwrap();
assert_eq!(cleared_group_data.name, new_name);
assert_eq!(cleared_group_data.description, new_description);
assert_eq!(cleared_group_data.image_hash, None);
assert_eq!(cleared_group_data.image_key, None);
assert_eq!(cleared_group_data.image_nonce, None);
assert_eq!(cleared_group_data.image_upload_key, None);
let empty_update = NostrGroupDataUpdate::new();
let update_result = creator_mdk
.update_group_data(group_id, empty_update)
.expect("Failed to apply empty update");
assert!(!update_result.evolution_event.content.is_empty());
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let unchanged_mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let unchanged_group_data =
NostrGroupDataExtension::from_group(&unchanged_mls_group).unwrap();
assert_eq!(unchanged_group_data.name, cleared_group_data.name);
assert_eq!(
unchanged_group_data.description,
cleared_group_data.description
);
assert_eq!(
unchanged_group_data.image_hash,
cleared_group_data.image_hash
);
assert_eq!(unchanged_group_data.image_key, cleared_group_data.image_key);
}
#[test]
fn test_sync_group_metadata_from_mls() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins.clone()),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let initial_stored_group = creator_mdk
.get_group(group_id)
.expect("Failed to get initial stored group")
.expect("Stored group should exist");
let mut mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let mut new_group_data = NostrGroupDataExtension::from_group(&mls_group).unwrap();
new_group_data.name = "Synchronized Name".to_string();
new_group_data.description = "Synchronized Description".to_string();
let extension =
super::MDK::<MdkMemoryStorage>::get_unknown_extension_from_group_data(&new_group_data)
.unwrap();
let mut extensions = mls_group.extensions().clone();
extensions.add_or_replace(extension).unwrap();
let signature_keypair = creator_mdk.load_mls_signer(&mls_group).unwrap();
let (_message_out, _, _) = mls_group
.update_group_context_extensions(&creator_mdk.provider, extensions, &signature_keypair)
.unwrap();
mls_group
.merge_pending_commit(&creator_mdk.provider)
.unwrap();
let stale_stored_group = creator_mdk
.get_group(group_id)
.expect("Failed to get stale stored group")
.expect("Stored group should exist");
assert_eq!(stale_stored_group.name, initial_stored_group.name);
assert_eq!(
stale_stored_group.description,
initial_stored_group.description
);
assert_eq!(stale_stored_group.epoch, initial_stored_group.epoch);
creator_mdk
.sync_group_metadata_from_mls(group_id)
.expect("Failed to sync group metadata");
let synced_stored_group = creator_mdk
.get_group(group_id)
.expect("Failed to get synced stored group")
.expect("Stored group should exist");
assert_eq!(synced_stored_group.name, "Synchronized Name");
assert_eq!(synced_stored_group.description, "Synchronized Description");
assert!(synced_stored_group.epoch > initial_stored_group.epoch);
assert_eq!(
synced_stored_group.admin_pubkeys,
admins.into_iter().collect::<BTreeSet<_>>()
);
assert_eq!(
synced_stored_group.mls_group_id,
initial_stored_group.mls_group_id
);
assert_eq!(
synced_stored_group.last_message_id,
initial_stored_group.last_message_id
);
assert_eq!(
synced_stored_group.last_message_at,
initial_stored_group.last_message_at
);
assert_eq!(synced_stored_group.state, initial_stored_group.state);
}
#[test]
fn test_sync_group_metadata_rejects_adminless_group_data() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let group_id = create_test_group(&creator_mdk, &creator, &initial_members, &admins);
let initial_stored_group = creator_mdk
.get_group(&group_id)
.expect("Failed to get initial stored group")
.expect("Stored group should exist");
let mut mls_group = creator_mdk
.load_mls_group(&group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let mut group_data =
NostrGroupDataExtension::from_group(&mls_group).expect("Group data should parse");
group_data.admins.clear();
let extension =
super::MDK::<MdkMemoryStorage>::get_unknown_extension_from_group_data(&group_data)
.expect("Failed to build adminless group-data extension");
let mut extensions = mls_group.extensions().clone();
extensions
.add_or_replace(extension)
.expect("Failed to replace group-data extension");
let signature_keypair = creator_mdk
.load_mls_signer(&mls_group)
.expect("Failed to load signer");
let (_message_out, _, _) = mls_group
.update_group_context_extensions(&creator_mdk.provider, extensions, &signature_keypair)
.expect("OpenMLS should allow the adminless extension update");
mls_group
.merge_pending_commit(&creator_mdk.provider)
.expect("OpenMLS should merge the adminless extension update");
let result = creator_mdk.sync_group_metadata_from_mls(&group_id);
assert!(
matches!(result, Err(crate::Error::Group(ref msg)) if msg.contains("no admins")),
"sync should reject adminless group data, got: {:?}",
result
);
let stored_group = creator_mdk
.get_group(&group_id)
.expect("Failed to reload stored group")
.expect("Stored group should still exist");
assert_eq!(
stored_group.admin_pubkeys, initial_stored_group.admin_pubkeys,
"Rejected sync should not alter stored admins"
);
}
#[test]
fn test_extension_updates_create_processed_messages() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let test_cases = vec![
("update_group_name", "New Name"),
("update_group_description", "New Description"),
];
for (operation, _value) in test_cases {
let update_result = match operation {
"update_group_name" => {
let update = NostrGroupDataUpdate::new().name("New Name".to_string());
creator_mdk.update_group_data(group_id, update)
}
"update_group_description" => {
let update =
NostrGroupDataUpdate::new().description("New Description".to_string());
creator_mdk.update_group_data(group_id, update)
}
_ => panic!("Unknown operation"),
};
let update_result = update_result.unwrap_or_else(|_| panic!("Failed to {}", operation));
let commit_event_id = update_result.evolution_event.id;
let processed_message = creator_mdk
.storage()
.find_processed_message_by_event_id(&commit_event_id)
.expect("Failed to query processed message")
.expect("ProcessedMessage should exist");
assert_eq!(processed_message.wrapper_event_id, commit_event_id);
assert_eq!(processed_message.message_event_id, None);
assert_eq!(
processed_message.state,
message_types::ProcessedMessageState::ProcessedCommit
);
assert_eq!(processed_message.failure_reason, None);
creator_mdk
.merge_pending_commit(group_id)
.unwrap_or_else(|_| panic!("Failed to merge pending commit for {}", operation));
}
}
#[test]
fn test_stored_group_sync_after_all_operations() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
let verify_epoch_sync = || {
let mls_group = creator_mdk.load_mls_group(group_id).unwrap().unwrap();
let stored_group = creator_mdk.get_group(group_id).unwrap().unwrap();
assert_eq!(
stored_group.epoch,
mls_group.epoch().as_u64(),
"Stored group epoch should match MLS group epoch"
);
};
verify_epoch_sync();
let new_member = Keys::generate();
let new_key_package_event = create_key_package_event(&creator_mdk, &new_member);
let _add_result = creator_mdk
.add_members(group_id, &[new_key_package_event])
.expect("Failed to add member");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for add member");
verify_epoch_sync();
let _self_update_result = creator_mdk
.self_update(group_id)
.expect("Failed to perform self update");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for self update");
verify_epoch_sync();
let update = NostrGroupDataUpdate::new().name("Final Name".to_string());
let _name_result = creator_mdk
.update_group_data(group_id, update)
.expect("Failed to update group name");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit for name update");
verify_epoch_sync();
let final_mls_group = creator_mdk.load_mls_group(group_id).unwrap().unwrap();
let final_stored_group = creator_mdk.get_group(group_id).unwrap().unwrap();
let final_group_data = NostrGroupDataExtension::from_group(&final_mls_group).unwrap();
assert_eq!(final_stored_group.name, final_group_data.name);
assert_eq!(final_stored_group.description, final_group_data.description);
assert_eq!(final_stored_group.admin_pubkeys, final_group_data.admins);
assert_eq!(
final_stored_group.nostr_group_id,
final_group_data.nostr_group_id
);
}
#[test]
fn test_sync_group_metadata_error_cases() {
let creator_mdk = create_test_mdk();
let non_existent_group_id = crate::GroupId::from_slice(&[1, 2, 3, 4, 5]);
let result = creator_mdk.sync_group_metadata_from_mls(&non_existent_group_id);
assert!(matches!(result, Err(crate::Error::GroupNotFound)));
}
#[test]
fn test_sync_group_metadata_propagates_extension_parse_failure() {
use openmls::prelude::{Extension, UnknownExtension};
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins.clone()),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id;
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let mut mls_group = creator_mdk
.load_mls_group(group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let corrupted_extension_data = vec![0xFF, 0xFF, 0xFF]; let corrupted_extension = Extension::Unknown(
NOSTR_GROUP_DATA_EXTENSION_TYPE,
UnknownExtension(corrupted_extension_data),
);
let mut extensions = mls_group.extensions().clone();
extensions.add_or_replace(corrupted_extension).unwrap();
let signature_keypair = creator_mdk.load_mls_signer(&mls_group).unwrap();
let (_message_out, _, _) = mls_group
.update_group_context_extensions(&creator_mdk.provider, extensions, &signature_keypair)
.unwrap();
mls_group
.merge_pending_commit(&creator_mdk.provider)
.unwrap();
let result = creator_mdk.sync_group_metadata_from_mls(group_id);
assert!(
result.is_err(),
"sync_group_metadata_from_mls should propagate extension parse errors"
);
match result {
Err(e) => {
let error_msg = e.to_string();
assert!(
error_msg.contains("TLS")
|| error_msg.contains("deserialize")
|| error_msg.contains("EndOfStream"),
"Expected deserialization error, got: {}",
error_msg
);
}
Ok(_) => panic!("Expected error but got Ok"),
}
}
#[test]
fn test_get_nonexistent_group() {
let mdk = create_test_mdk();
let non_existent_id = crate::GroupId::from_slice(&[9, 9, 9, 9]);
let result = mdk.get_group(&non_existent_id);
assert!(result.is_ok(), "Should succeed");
assert!(
result.unwrap().is_none(),
"Should return None for non-existent group"
);
}
#[test]
fn test_member_self_removal() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let admins = vec![alice_keys.public_key()];
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package],
create_nostr_group_config_data(admins),
)
.expect("Alice should be able to create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge Alice's create commit");
let bob_welcome_rumor = &create_result.welcome_rumors[0];
let bob_welcome = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
.expect("Bob should be able to process welcome");
bob_mdk
.accept_welcome(&bob_welcome)
.expect("Bob should be able to accept welcome");
let initial_members = alice_mdk
.get_members(&group_id)
.expect("Failed to get members");
assert_eq!(initial_members.len(), 2, "Group should have 2 members");
let bob_leave_result = bob_mdk.leave_group(&group_id);
assert!(
bob_leave_result.is_ok(),
"Bob should be able to call leave_group: {:?}",
bob_leave_result.err()
);
let bob_leave_event = bob_leave_result.unwrap().evolution_event;
assert_eq!(
bob_leave_event.kind,
nostr::Kind::MlsGroupMessage,
"Leave should generate MLS group message event"
);
assert!(
bob_leave_event.tags.iter().any(|t| t.kind()
== nostr::TagKind::SingleLetter(nostr::SingleLetterTag::from_char('h').unwrap())),
"Leave event should have group ID tag"
);
let members_after_leave_call = alice_mdk
.get_members(&group_id)
.expect("Failed to get members after leave call");
assert_eq!(
members_after_leave_call.len(),
2,
"Bob should still be in group - leave hasn't been processed yet"
);
assert!(
members_after_leave_call.contains(&bob_keys.public_key()),
"Bob should still be in member list until another member processes the leave"
);
let process_result = alice_mdk.process_message(&bob_leave_event);
assert!(
process_result.is_ok(),
"Alice should be able to process Bob's leave event: {:?}",
process_result.err()
);
let _merge_result = alice_mdk.merge_pending_commit(&group_id);
let final_members = alice_mdk
.get_members(&group_id)
.expect("Failed to get members");
assert!(
final_members.len() <= 2,
"Group should have at most 2 members after processing leave"
);
}
#[test]
fn test_cannot_add_existing_member() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let admins = vec![alice_keys.public_key()];
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package.clone()],
create_nostr_group_config_data(admins),
)
.expect("Alice should be able to create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge Alice's create commit");
let bob_welcome_rumor = &create_result.welcome_rumors[0];
let bob_welcome = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
.expect("Bob should be able to process welcome");
bob_mdk
.accept_welcome(&bob_welcome)
.expect("Bob should be able to accept welcome");
let initial_members = alice_mdk
.get_members(&group_id)
.expect("Failed to get members");
assert_eq!(initial_members.len(), 2, "Group should have 2 members");
let add_duplicate_result = alice_mdk.add_members(&group_id, &[bob_key_package]);
assert!(
add_duplicate_result.is_err(),
"Should not be able to add existing member with same KeyPackage"
);
let members_after_duplicate = alice_mdk
.get_members(&group_id)
.expect("Failed to get members");
assert_eq!(
members_after_duplicate.len(),
2,
"Member count should not change after rejected duplicate add"
);
let remove_result = alice_mdk
.remove_members(&group_id, &[bob_keys.public_key()])
.expect("Should be able to remove Bob");
alice_mdk
.process_message(&remove_result.evolution_event)
.expect("Failed to process remove");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge remove commit");
let members_after_remove = alice_mdk
.get_members(&group_id)
.expect("Failed to get members");
assert_eq!(
members_after_remove.len(),
1,
"Group should have 1 member after removal"
);
assert!(
!members_after_remove.contains(&bob_keys.public_key()),
"Bob should not be in group"
);
let bob_new_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let readd_result = alice_mdk.add_members(&group_id, &[bob_new_key_package]);
assert!(
readd_result.is_ok(),
"Should be able to re-add Bob after removal: {:?}",
readd_result.err()
);
alice_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge re-add commit");
let final_members = alice_mdk
.get_members(&group_id)
.expect("Failed to get members");
assert_eq!(final_members.len(), 2, "Group should have 2 members again");
assert!(
final_members.contains(&bob_keys.public_key()),
"Bob should be back in group"
);
}
#[test]
fn test_non_admin_cannot_add_members() {
let creator_mdk = create_test_mdk();
let creator = Keys::generate();
let non_admin_keys = Keys::generate();
let admins = vec![creator.public_key()];
let non_admin_mdk = create_test_mdk();
let non_admin_key_package = create_key_package_event(&non_admin_mdk, &non_admin_keys);
let create_result = creator_mdk
.create_group(
&creator.public_key(),
vec![non_admin_key_package],
create_nostr_group_config_data(admins.clone()),
)
.expect("Failed to create group");
let group_id = create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
let non_admin_welcome_rumor = &create_result.welcome_rumors[0];
let non_admin_welcome = non_admin_mdk
.process_welcome(&nostr::EventId::all_zeros(), non_admin_welcome_rumor)
.expect("Non-admin should process welcome");
non_admin_mdk
.accept_welcome(&non_admin_welcome)
.expect("Non-admin should accept welcome");
assert!(
!admins.contains(&non_admin_keys.public_key()),
"Non-admin should not be in admin list"
);
let initial_member_count = creator_mdk
.get_members(&group_id)
.expect("Failed to get members")
.len();
let new_member_keys = Keys::generate();
let new_member_key_package = create_key_package_event(&non_admin_mdk, &new_member_keys);
let result = non_admin_mdk.add_members(&group_id, &[new_member_key_package]);
assert!(
matches!(result, Err(Error::NotAdmin)),
"Should fail with typed admin permission error, got: {:?}",
result
);
let final_member_count = creator_mdk
.get_members(&group_id)
.expect("Failed to get members")
.len();
assert_eq!(
initial_member_count, final_member_count,
"Member count should not change when non-admin attempts to add members"
);
}
#[test]
fn test_non_admin_cannot_remove_members() {
let creator_mdk = create_test_mdk();
let creator = Keys::generate();
let non_admin_keys = Keys::generate();
let other_member_keys = Keys::generate();
let admins = vec![creator.public_key()];
let non_admin_mdk = create_test_mdk();
let other_member_mdk = create_test_mdk();
let non_admin_key_package = create_key_package_event(&non_admin_mdk, &non_admin_keys);
let other_member_key_package =
create_key_package_event(&other_member_mdk, &other_member_keys);
let create_result = creator_mdk
.create_group(
&creator.public_key(),
vec![non_admin_key_package, other_member_key_package],
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
let non_admin_welcome_rumor = &create_result.welcome_rumors[0];
let non_admin_welcome = non_admin_mdk
.process_welcome(&nostr::EventId::all_zeros(), non_admin_welcome_rumor)
.expect("Non-admin should process welcome");
non_admin_mdk
.accept_welcome(&non_admin_welcome)
.expect("Non-admin should accept welcome");
let initial_member_count = creator_mdk
.get_members(&group_id)
.expect("Failed to get members")
.len();
let result = non_admin_mdk.remove_members(&group_id, &[other_member_keys.public_key()]);
assert!(
matches!(result, Err(Error::NotAdmin)),
"Should fail with typed admin permission error, got: {:?}",
result
);
let final_members_list = creator_mdk
.get_members(&group_id)
.expect("Failed to get members");
let final_member_count = final_members_list.len();
assert_eq!(
initial_member_count, final_member_count,
"Member count should not change when non-admin attempts to remove members"
);
assert!(
final_members_list
.iter()
.any(|m| m == &other_member_keys.public_key()),
"Target member should still be in the group"
);
}
#[test]
fn test_non_admin_cannot_update_group_extensions() {
let creator_mdk = create_test_mdk();
let creator = Keys::generate();
let non_admin_keys = Keys::generate();
let admins = vec![creator.public_key()];
let non_admin_mdk = create_test_mdk();
let non_admin_key_package = create_key_package_event(&non_admin_mdk, &non_admin_keys);
let create_result = creator_mdk
.create_group(
&creator.public_key(),
vec![non_admin_key_package],
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
let non_admin_welcome_rumor = &create_result.welcome_rumors[0];
let non_admin_welcome = non_admin_mdk
.process_welcome(&nostr::EventId::all_zeros(), non_admin_welcome_rumor)
.expect("Non-admin should process welcome");
non_admin_mdk
.accept_welcome(&non_admin_welcome)
.expect("Non-admin should accept welcome");
let initial_group = creator_mdk
.get_group(&group_id)
.expect("Failed to get group")
.expect("Group should exist");
let initial_name = initial_group.name.clone();
let initial_description = initial_group.description.clone();
let update = NostrGroupDataUpdate::new().name("Hacked Name".to_string());
let result = non_admin_mdk.update_group_data(&group_id, update);
assert!(
matches!(result, Err(Error::NotAdmin)),
"Should fail with typed admin permission error, got: {:?}",
result
);
let final_group = creator_mdk
.get_group(&group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert_eq!(
initial_name, final_group.name,
"Group name should not change when non-admin attempts to update"
);
assert_eq!(
initial_description, final_group.description,
"Group description should not change when non-admin attempts to update"
);
}
#[test]
fn test_non_admin_cannot_self_demote() {
let creator_mdk = create_test_mdk();
let creator = Keys::generate();
let non_admin_keys = Keys::generate();
let non_admin_mdk = create_test_mdk();
let non_admin_key_package = create_key_package_event(&non_admin_mdk, &non_admin_keys);
let create_result = creator_mdk
.create_group(
&creator.public_key(),
vec![non_admin_key_package],
create_nostr_group_config_data(vec![creator.public_key()]),
)
.expect("Failed to create group");
let group_id = create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
let non_admin_welcome = non_admin_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("Non-admin should process welcome");
non_admin_mdk
.accept_welcome(&non_admin_welcome)
.expect("Non-admin should accept welcome");
let result = non_admin_mdk.self_demote(&group_id);
assert!(
matches!(result, Err(Error::NotAdmin)),
"Should fail with typed admin permission error, got: {:?}",
result
);
}
#[test]
fn test_creator_validation_errors() {
let mdk = create_test_mdk();
let creator = Keys::generate();
let member1 = Keys::generate();
let member2 = Keys::generate();
let creator_pk = creator.public_key();
let member_pks = vec![member1.public_key(), member2.public_key()];
let bad_admins = vec![member1.public_key()];
let result = mdk.validate_group_members(&creator_pk, &member_pks, &bad_admins);
assert!(
matches!(result, Err(crate::Error::Group(ref msg)) if msg.contains("Creator must be an admin")),
"Should error when creator is not an admin"
);
let bad_members = vec![creator_pk, member1.public_key()];
let admins = vec![creator_pk];
let result = mdk.validate_group_members(&creator_pk, &bad_members, &admins);
assert!(
matches!(result, Err(crate::Error::Group(ref msg)) if msg.contains("Creator must not be included as a member")),
"Should error when creator is in member list"
);
let non_member_admin = Keys::generate().public_key();
let bad_admins = vec![creator_pk, non_member_admin];
let result = mdk.validate_group_members(&creator_pk, &member_pks, &bad_admins);
assert!(
matches!(result, Err(crate::Error::Group(ref msg)) if msg.contains("Admin must be a member")),
"Should error when admin is not a member"
);
}
#[test]
fn test_admin_update_rejects_empty_admin_set() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let empty_admins: Vec<PublicKey> = vec![];
let update = NostrGroupDataUpdate::new().admins(empty_admins);
let result = creator_mdk.update_group_data(group_id, update);
assert!(
matches!(result, Err(crate::Error::UpdateGroupContextExts(ref msg)) if msg.contains("Admin set cannot be empty")),
"Should error when admin set is empty, got: {:?}",
result
);
}
#[test]
fn test_admin_update_rejects_all_non_member_admins() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let non_member1 = Keys::generate().public_key();
let non_member2 = Keys::generate().public_key();
let all_non_members = vec![non_member1, non_member2];
let update = NostrGroupDataUpdate::new().admins(all_non_members);
let result = creator_mdk.update_group_data(group_id, update);
assert!(
matches!(result, Err(crate::Error::UpdateGroupContextExts(ref msg)) if msg.contains("Admin set cannot be empty")),
"Should error when all admins are pruned, got: {:?}",
result
);
}
#[test]
fn test_admin_update_prunes_non_member_admins() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let non_member = Keys::generate().public_key();
let admins_with_non_member = vec![creator_pk, non_member];
let update = NostrGroupDataUpdate::new().admins(admins_with_non_member);
let result = creator_mdk.update_group_data(group_id, update);
assert!(
result.is_ok(),
"Should succeed after pruning non-member admin, got: {:?}",
result
);
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let synced_group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert!(
synced_group.admin_pubkeys.contains(&creator_pk),
"Creator should remain as admin"
);
assert!(
!synced_group.admin_pubkeys.contains(&non_member),
"Non-member should have been pruned from admin set"
);
}
#[test]
fn test_admin_update_accepts_valid_member_admins() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let members = creator_mdk
.get_members(group_id)
.expect("Failed to get members");
let new_admins: Vec<PublicKey> = members.into_iter().collect();
let update = NostrGroupDataUpdate::new().admins(new_admins.clone());
let result = creator_mdk.update_group_data(group_id, update);
assert!(
result.is_ok(),
"Should succeed when all admins are current members, got: {:?}",
result
);
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
creator_mdk
.sync_group_metadata_from_mls(group_id)
.expect("Failed to sync");
let synced_group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
let expected_admins: BTreeSet<PublicKey> = new_admins.into_iter().collect();
assert_eq!(
synced_group.admin_pubkeys, expected_admins,
"Admin pubkeys should be updated to the new set"
);
}
#[test]
fn test_admin_update_prunes_previously_removed_member() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let member1_pk = initial_members[0].public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
creator_mdk
.remove_members(group_id, &[member1_pk])
.expect("Failed to remove member");
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let admins_with_removed = vec![creator_pk, member1_pk];
let update = NostrGroupDataUpdate::new().admins(admins_with_removed);
let result = creator_mdk.update_group_data(group_id, update);
assert!(
result.is_ok(),
"Should succeed after pruning removed member, got: {:?}",
result
);
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let synced_group = creator_mdk
.get_group(group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert!(
synced_group.admin_pubkeys.contains(&creator_pk),
"Creator should remain as admin"
);
assert!(
!synced_group.admin_pubkeys.contains(&member1_pk),
"Removed member should have been pruned from admin set"
);
}
#[test]
fn test_get_groups_empty() {
let mdk = create_test_mdk();
let groups = mdk.get_groups().expect("Should succeed");
assert_eq!(groups.len(), 0, "Should have no groups initially");
}
#[test]
fn test_get_groups_with_data() {
let creator_mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&creator_mdk, &creator, &members, &admins);
let groups = creator_mdk.get_groups().expect("Should succeed");
assert_eq!(groups.len(), 1, "Should have 1 group");
assert_eq!(groups[0].mls_group_id, group_id, "Group ID should match");
}
#[test]
fn test_get_relays() {
let creator_mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&creator_mdk, &creator, &members, &admins);
let relays = creator_mdk
.get_relays(&group_id)
.expect("Should get relays");
assert!(!relays.is_empty(), "Group should have relays");
}
#[test]
fn test_get_members_nonexistent_group() {
let mdk = create_test_mdk();
let non_existent_id = crate::GroupId::from_slice(&[9, 9, 9, 9]);
let result = mdk.get_members(&non_existent_id);
assert!(result.is_err(), "Should fail for non-existent group");
}
#[test]
fn test_group_metadata_updates() {
let creator_mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&creator_mdk, &creator, &members, &admins);
let update = NostrGroupDataUpdate::new().name("New Name".to_string());
let result = creator_mdk.update_group_data(&group_id, update);
assert!(result.is_ok(), "Should be able to update group name");
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
let update = NostrGroupDataUpdate::new().description("New Description".to_string());
let result = creator_mdk.update_group_data(&group_id, update);
assert!(result.is_ok(), "Should be able to update group description");
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
let update = NostrGroupDataUpdate::new()
.name("Final Name".to_string())
.description("Final Description".to_string());
let result = creator_mdk.update_group_data(&group_id, update);
assert!(
result.is_ok(),
"Should be able to update both name and description"
);
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
}
#[test]
fn test_group_with_empty_name() {
let creator_mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&creator_mdk, &creator, &members, &admins);
let update = NostrGroupDataUpdate::new().name("".to_string());
let result = creator_mdk.update_group_data(&group_id, update);
assert!(result.is_ok(), "Empty group name should be valid");
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
}
#[test]
fn test_group_with_long_name() {
let creator_mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&creator_mdk, &creator, &members, &admins);
let long_name = "a".repeat(256);
let update = NostrGroupDataUpdate::new().name(long_name);
let result = creator_mdk.update_group_data(&group_id, update);
assert!(result.is_ok(), "Group name at limit should be valid");
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
}
#[test]
fn test_update_nostr_group_id() {
let creator_mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&creator_mdk, &creator, &members, &admins);
let initial_mls_group = creator_mdk
.load_mls_group(&group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let initial_group_data = NostrGroupDataExtension::from_group(&initial_mls_group).unwrap();
let initial_nostr_group_id = initial_group_data.nostr_group_id;
let new_nostr_group_id: [u8; 32] = [42u8; 32];
let update = NostrGroupDataUpdate::new().nostr_group_id(new_nostr_group_id);
let result = creator_mdk.update_group_data(&group_id, update);
assert!(result.is_ok(), "Should be able to update nostr_group_id");
creator_mdk
.merge_pending_commit(&group_id)
.expect("Failed to merge commit");
let final_mls_group = creator_mdk
.load_mls_group(&group_id)
.expect("Failed to load MLS group")
.expect("MLS group should exist");
let final_group_data = NostrGroupDataExtension::from_group(&final_mls_group).unwrap();
assert_ne!(
final_group_data.nostr_group_id, initial_nostr_group_id,
"nostr_group_id should have changed"
);
assert_eq!(
final_group_data.nostr_group_id, new_nostr_group_id,
"nostr_group_id should match the new value"
);
let stored_group = creator_mdk
.get_group(&group_id)
.expect("Failed to get group")
.expect("Group should exist");
assert_eq!(
stored_group.nostr_group_id, new_nostr_group_id,
"Stored group nostr_group_id should be synced"
);
}
#[test]
fn test_operation_from_removed_member() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let dave_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let dave_mdk = create_test_mdk();
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let admin_pubkeys = vec![alice_keys.public_key(), bob_keys.public_key()];
let config = create_nostr_group_config_data(admin_pubkeys);
let create_result = alice_mdk
.create_group(&alice_keys.public_key(), vec![bob_key_package], config)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let bob_welcome_rumor = &create_result.welcome_rumors[0];
let bob_welcome = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
.expect("Bob should process welcome");
bob_mdk
.accept_welcome(&bob_welcome)
.expect("Bob should accept welcome");
let charlie_key_package = create_key_package_event(&charlie_mdk, &charlie_keys);
let bob_add_charlie = bob_mdk
.add_members(&group_id, &[charlie_key_package])
.expect("Bob should be able to add Charlie as admin");
bob_mdk
.merge_pending_commit(&group_id)
.expect("Bob should merge commit");
alice_mdk
.process_message(&bob_add_charlie.evolution_event)
.expect("Alice should process Bob's commit");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let members_after_charlie = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(
members_after_charlie.len(),
3,
"Should have 3 members (Alice, Bob, Charlie)"
);
assert!(
members_after_charlie.contains(&charlie_keys.public_key()),
"Charlie should be in the group"
);
let _remove_bob = alice_mdk
.remove_members(&group_id, &[bob_keys.public_key()])
.expect("Alice should remove Bob");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge removal commit");
let members_after_removal = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(
members_after_removal.len(),
2,
"Should have 2 members after Bob's removal"
);
assert!(
!members_after_removal.contains(&bob_keys.public_key()),
"Bob should not be in Alice's member list"
);
let dave_key_package = create_key_package_event(&dave_mdk, &dave_keys);
let bob_add_dave = bob_mdk.add_members(&group_id, &[dave_key_package]);
if let Ok(bob_add_result) = bob_add_dave {
let alice_process_result = alice_mdk.process_message(&bob_add_result.evolution_event);
if alice_process_result.is_ok() {
let _merge_result = alice_mdk.merge_pending_commit(&group_id);
}
}
let final_members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(
final_members.len(),
2,
"Should still have 2 members (Alice and Charlie)"
);
assert!(
!final_members.contains(&dave_keys.public_key()),
"Dave should not be in the group"
);
}
#[test]
fn test_rapid_sequential_member_operations() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let admin_pubkeys = vec![alice_keys.public_key()];
let config = create_nostr_group_config_data(admin_pubkeys);
let bob_keys = Keys::generate();
let bob_mdk = create_test_mdk();
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(&alice_keys.public_key(), vec![bob_key_package], config)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let bob_welcome_rumor = &create_result.welcome_rumors[0];
let bob_welcome = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
.expect("Bob should process welcome");
bob_mdk
.accept_welcome(&bob_welcome)
.expect("Bob should accept welcome");
let initial_epoch = alice_mdk
.get_group(&group_id)
.expect("Should get group")
.expect("Group should exist")
.epoch;
let mut member_add_events = Vec::new();
for i in 0..3 {
let member_keys = Keys::generate();
let member_mdk = create_test_mdk();
let member_key_package = create_key_package_event(&member_mdk, &member_keys);
let add_result = alice_mdk
.add_members(&group_id, &[member_key_package])
.unwrap_or_else(|_| panic!("Should add member {}", i));
alice_mdk
.merge_pending_commit(&group_id)
.unwrap_or_else(|_| panic!("Should merge commit {}", i));
member_add_events.push(add_result.evolution_event);
}
for (i, event) in member_add_events.iter().enumerate() {
bob_mdk
.process_message(event)
.unwrap_or_else(|_| panic!("Bob should process add commit {}", i));
bob_mdk
.merge_pending_commit(&group_id)
.unwrap_or_else(|_| panic!("Bob should merge commit {}", i));
}
let after_adds_epoch = alice_mdk
.get_group(&group_id)
.expect("Should get group")
.expect("Group should exist")
.epoch;
assert!(
after_adds_epoch > initial_epoch,
"Epoch should advance after additions"
);
let alice_members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(
alice_members.len(),
5,
"Alice should see 5 members after additions"
);
let bob_group = bob_mdk
.get_group(&group_id)
.expect("Bob should have group")
.expect("Group should exist for Bob");
assert_eq!(
bob_group.epoch, after_adds_epoch,
"Bob's epoch should match Alice's"
);
let bob_members = bob_mdk
.get_members(&group_id)
.expect("Bob should get members");
assert_eq!(bob_members.len(), 5, "Bob should see 5 members");
for member in &alice_members {
assert!(
bob_members.contains(member),
"Bob should see member {:?}",
member
);
}
}
#[test]
fn test_member_operation_state_consistency() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let admin_pubkeys = vec![alice_keys.public_key()];
let config = create_nostr_group_config_data(admin_pubkeys);
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(&alice_keys.public_key(), vec![bob_key_package], config)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let initial_group = alice_mdk
.get_group(&group_id)
.expect("Should get group")
.expect("Group should exist");
let initial_members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
let initial_epoch = initial_group.epoch;
assert_eq!(initial_members.len(), 2, "Should have 2 initial members");
let charlie_key_package = create_key_package_event(&charlie_mdk, &charlie_keys);
alice_mdk
.add_members(&group_id, &[charlie_key_package])
.expect("Should add Charlie");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Should merge commit");
let after_add_group = alice_mdk
.get_group(&group_id)
.expect("Should get group")
.expect("Group should exist");
let after_add_members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(
after_add_members.len(),
3,
"Should have 3 members after add"
);
assert!(
after_add_group.epoch > initial_epoch,
"Epoch should advance after add"
);
assert!(
after_add_members.contains(&charlie_keys.public_key()),
"Charlie should be in members list"
);
alice_mdk
.remove_members(&group_id, &[charlie_keys.public_key()])
.expect("Should remove Charlie");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Should merge commit");
let after_remove_group = alice_mdk
.get_group(&group_id)
.expect("Should get group")
.expect("Group should exist");
let after_remove_members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(
after_remove_members.len(),
2,
"Should have 2 members after removal"
);
assert!(
after_remove_group.epoch > after_add_group.epoch,
"Epoch should advance after removal"
);
assert!(
!after_remove_members.contains(&charlie_keys.public_key()),
"Charlie should not be in members list"
);
assert!(
after_remove_members.contains(&alice_keys.public_key()),
"Alice should still be in group"
);
assert!(
after_remove_members.contains(&bob_keys.public_key()),
"Bob should still be in group"
);
}
#[test]
fn test_remove_members_with_tree_holes() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let dave_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let dave_mdk = create_test_mdk();
let admin_pubkeys = vec![alice_keys.public_key()];
let config = create_nostr_group_config_data(admin_pubkeys);
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_key_package = create_key_package_event(&charlie_mdk, &charlie_keys);
let dave_key_package = create_key_package_event(&dave_mdk, &dave_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package, charlie_key_package, dave_key_package],
config,
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let initial_members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(initial_members.len(), 4, "Should have 4 members initially");
alice_mdk
.remove_members(&group_id, &[charlie_keys.public_key()])
.expect("Should remove Charlie");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Should merge commit");
let after_charlie_removal = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(
after_charlie_removal.len(),
3,
"Should have 3 members after removing Charlie"
);
assert!(
!after_charlie_removal.contains(&charlie_keys.public_key()),
"Charlie should be removed"
);
alice_mdk
.remove_members(&group_id, &[dave_keys.public_key()])
.expect("Should remove Dave");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Should merge commit");
let final_members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(
final_members.len(),
2,
"Should have 2 members after removals"
);
assert!(
final_members.contains(&alice_keys.public_key()),
"Alice should still be in group"
);
assert!(
final_members.contains(&bob_keys.public_key()),
"Bob should still be in group"
);
assert!(
!final_members.contains(&dave_keys.public_key()),
"Dave should be removed"
);
}
#[test]
fn test_empty_group_operations() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let admin_pubkeys = vec![alice_keys.public_key()];
let config = create_nostr_group_config_data(admin_pubkeys);
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(&alice_keys.public_key(), vec![bob_key_package], config)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let empty_remove_result = alice_mdk.remove_members(&group_id, &[]);
assert!(
empty_remove_result.is_err(),
"Removing empty member list should fail"
);
let members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(members.len(), 2, "Member count should not change");
let empty_add_result = alice_mdk.add_members(&group_id, &[]);
assert!(
empty_add_result.is_err(),
"Adding empty member list should fail"
);
let members = alice_mdk
.get_members(&group_id)
.expect("Should get members");
assert_eq!(members.len(), 2, "Member count should not change");
}
#[test]
fn test_pending_added_members_pubkeys_empty() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let admins = vec![alice_keys.public_key()];
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package],
create_nostr_group_config_data(admins),
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let pending = alice_mdk
.pending_added_members_pubkeys(&group_id)
.expect("Should get pending added members");
assert!(
pending.is_empty(),
"No pending additions when no proposals have been received"
);
}
#[test]
fn test_self_remove_auto_committed_no_pending_removals() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let admins = vec![alice_keys.public_key()];
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_key_package = create_key_package_event(&charlie_mdk, &charlie_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package, charlie_key_package],
create_nostr_group_config_data(admins),
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let bob_welcome = &create_result.welcome_rumors[0];
let charlie_welcome = &create_result.welcome_rumors[1];
let bob_welcome_preview = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome)
.expect("Bob should process welcome");
bob_mdk
.accept_welcome(&bob_welcome_preview)
.expect("Bob should accept welcome");
let charlie_welcome_preview = charlie_mdk
.process_welcome(&nostr::EventId::all_zeros(), charlie_welcome)
.expect("Charlie should process welcome");
charlie_mdk
.accept_welcome(&charlie_welcome_preview)
.expect("Charlie should accept welcome");
let bob_leave_result = bob_mdk
.leave_group(&group_id)
.expect("Bob should be able to leave");
let process_result = charlie_mdk
.process_message(&bob_leave_result.evolution_event)
.expect("Charlie should process Bob's SelfRemove");
assert!(
matches!(process_result, MessageProcessingResult::Proposal(_)),
"SelfRemove should be auto-committed by non-admin, got: {:?}",
process_result
);
let pending = charlie_mdk
.pending_removed_members_pubkeys(&group_id)
.expect("Should get pending removed members");
assert!(
pending.is_empty(),
"No pending removals after SelfRemove auto-commit"
);
charlie_mdk
.merge_pending_commit(&group_id)
.expect("Charlie should merge pending commit");
let members = charlie_mdk
.get_members(&group_id)
.expect("Should get members");
assert!(
!members.contains(&bob_keys.public_key()),
"Bob should no longer be in the group after SelfRemove"
);
}
#[test]
fn test_self_remove_group_remains_functional() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let dave_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let dave_mdk = create_test_mdk();
let admins = vec![alice_keys.public_key()];
let bob_kp = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_kp = create_key_package_event(&charlie_mdk, &charlie_keys);
let dave_kp = create_key_package_event(&dave_mdk, &dave_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_kp, charlie_kp, dave_kp],
create_nostr_group_config_data(admins),
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
for (mdk, keys, idx) in [
(&bob_mdk, &bob_keys, 0),
(&charlie_mdk, &charlie_keys, 1),
(&dave_mdk, &dave_keys, 2),
] {
let welcome = &create_result.welcome_rumors[idx];
let preview = mdk
.process_welcome(&nostr::EventId::all_zeros(), welcome)
.unwrap_or_else(|_| panic!("{:?} should process welcome", keys.public_key()));
mdk.accept_welcome(&preview)
.unwrap_or_else(|_| panic!("{:?} should accept welcome", keys.public_key()));
}
let bob_leave = bob_mdk.leave_group(&group_id).expect("Bob should leave");
let charlie_result = charlie_mdk
.process_message(&bob_leave.evolution_event)
.expect("Charlie should process SelfRemove");
let charlie_commit = match charlie_result {
MessageProcessingResult::Proposal(update) => update.evolution_event,
other => panic!("Charlie should auto-commit, got: {:?}", other),
};
charlie_mdk
.merge_pending_commit(&group_id)
.expect("Charlie should merge commit");
for (name, mdk) in [("Alice", &alice_mdk), ("Dave", &dave_mdk)] {
let _ = mdk.process_message(&bob_leave.evolution_event);
let commit_result = mdk.process_message(&charlie_commit);
assert!(
commit_result.is_ok(),
"{name} should process Charlie's commit: {:?}",
commit_result.err()
);
}
for (name, mdk) in [
("Alice", &alice_mdk),
("Charlie", &charlie_mdk),
("Dave", &dave_mdk),
] {
let members = mdk.get_members(&group_id).expect("Should get members");
assert!(
!members.contains(&bob_keys.public_key()),
"Bob should be removed from {name}'s group"
);
}
let rumor = crate::test_util::create_test_rumor(&charlie_keys, "post-departure message");
let charlie_msg = charlie_mdk
.create_message(&group_id, rumor, None)
.expect("Charlie should send a message after SelfRemove");
for (name, mdk) in [("Alice", &alice_mdk), ("Dave", &dave_mdk)] {
let result = mdk.process_message(&charlie_msg);
assert!(
result.is_ok(),
"{name} should read Charlie's post-departure message: {:?}",
result.err()
);
}
}
#[test]
fn test_pending_member_changes_empty() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let admins = vec![alice_keys.public_key()];
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package],
create_nostr_group_config_data(admins),
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let changes = alice_mdk
.pending_member_changes(&group_id)
.expect("Should get pending member changes");
assert!(changes.additions.is_empty(), "No pending additions");
assert!(changes.removals.is_empty(), "No pending removals");
}
#[test]
fn test_no_pending_member_changes_after_self_remove() {
use crate::test_util::create_key_package_event;
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let admins = vec![alice_keys.public_key()];
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_key_package = create_key_package_event(&charlie_mdk, &charlie_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package, charlie_key_package],
create_nostr_group_config_data(admins),
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
let bob_welcome = &create_result.welcome_rumors[0];
let charlie_welcome = &create_result.welcome_rumors[1];
let bob_welcome_preview = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome)
.expect("Bob should process welcome");
bob_mdk
.accept_welcome(&bob_welcome_preview)
.expect("Bob should accept welcome");
let charlie_welcome_preview = charlie_mdk
.process_welcome(&nostr::EventId::all_zeros(), charlie_welcome)
.expect("Charlie should process welcome");
charlie_mdk
.accept_welcome(&charlie_welcome_preview)
.expect("Charlie should accept welcome");
let bob_leave_result = bob_mdk.leave_group(&group_id).expect("Bob should leave");
charlie_mdk
.process_message(&bob_leave_result.evolution_event)
.expect("Charlie should process Bob's SelfRemove");
let changes = charlie_mdk
.pending_member_changes(&group_id)
.expect("Should get pending member changes");
assert!(changes.additions.is_empty(), "No pending additions");
assert!(
changes.removals.is_empty(),
"No pending removals after SelfRemove auto-commit"
);
}
#[test]
fn test_pending_member_methods_group_not_found() {
let alice_mdk = create_test_mdk();
let fake_group_id = mdk_storage_traits::GroupId::from_slice(&[0u8; 16]);
let result = alice_mdk.pending_added_members_pubkeys(&fake_group_id);
assert!(result.is_err(), "Should error for non-existent group");
let result = alice_mdk.pending_removed_members_pubkeys(&fake_group_id);
assert!(result.is_err(), "Should error for non-existent group");
let result = alice_mdk.pending_member_changes(&fake_group_id);
assert!(result.is_err(), "Should error for non-existent group");
}
#[test]
fn test_clear_pending_commit_after_failed_add() {
let mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&mdk, &creator, &members, &admins);
let initial_members = mdk.get_members(&group_id).expect("get members");
let initial_count = initial_members.len();
let new_member = Keys::generate();
let kp_event = create_key_package_event(&mdk, &new_member);
let _add_result = mdk
.add_members(&group_id, &[kp_event])
.expect("add_members should succeed");
let another_member = Keys::generate();
let kp_event2 = create_key_package_event(&mdk, &another_member);
let err = mdk.add_members(&group_id, &[kp_event2]);
assert!(err.is_err(), "Should fail due to existing pending commit");
mdk.clear_pending_commit(&group_id)
.expect("clear_pending_commit should succeed");
let after_clear = mdk.get_members(&group_id).expect("get members");
assert_eq!(
after_clear.len(),
initial_count,
"Member count should be unchanged after clearing pending commit"
);
assert!(
!after_clear.contains(&new_member.public_key()),
"New member should not be in group after clearing pending commit"
);
let kp_event3 = create_key_package_event(&mdk, &another_member);
mdk.add_members(&group_id, &[kp_event3])
.expect("add_members should succeed after clearing pending commit");
mdk.merge_pending_commit(&group_id)
.expect("merge should succeed after clearing pending commit");
let final_members = mdk.get_members(&group_id).expect("get members");
assert_eq!(
final_members.len(),
initial_count + 1,
"Member should be added after clearing stale commit and retrying"
);
assert!(
final_members.contains(&another_member.public_key()),
"New member should be in group after successful retry"
);
}
#[test]
fn test_clear_pending_commit_after_failed_remove() {
let mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&mdk, &creator, &members, &admins);
let initial_members = mdk.get_members(&group_id).expect("get members");
let initial_count = initial_members.len();
let member_to_remove = members[0].public_key();
let _remove_result = mdk
.remove_members(&group_id, &[member_to_remove])
.expect("remove_members should succeed");
mdk.clear_pending_commit(&group_id)
.expect("clear_pending_commit should succeed");
let after_clear = mdk.get_members(&group_id).expect("get members");
assert_eq!(
after_clear.len(),
initial_count,
"Member count should be unchanged after clearing pending remove commit"
);
assert!(
after_clear.contains(&member_to_remove),
"Member should still be in group after clearing pending remove commit"
);
mdk.remove_members(&group_id, &[member_to_remove])
.expect("remove_members should succeed after clearing pending commit");
mdk.merge_pending_commit(&group_id)
.expect("merge should succeed after clearing pending commit");
let final_members = mdk.get_members(&group_id).expect("get members");
assert_eq!(
final_members.len(),
initial_count - 1,
"Member should be removed after clearing stale commit and retrying"
);
assert!(
!final_members.contains(&member_to_remove),
"Removed member should not be in group after successful retry"
);
}
#[test]
fn test_clear_pending_commit_no_pending() {
let mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&mdk, &creator, &members, &admins);
mdk.clear_pending_commit(&group_id)
.expect("clear_pending_commit should succeed even with no pending commit");
let member_count = mdk.get_members(&group_id).expect("get members").len();
assert!(member_count > 0, "Group should still have members");
}
#[test]
fn test_clear_pending_commit_group_not_found() {
let mdk = create_test_mdk();
let fake_group_id = mdk_storage_traits::GroupId::from_slice(&[0u8; 16]);
let result = mdk.clear_pending_commit(&fake_group_id);
assert!(
result.is_err(),
"clear_pending_commit should error for non-existent group"
);
}
#[test]
fn test_self_update_then_merge_no_orphan() {
let mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&mdk, &creator, &members, &admins);
let pre_update_mls_group = mdk
.load_mls_group(&group_id)
.expect("load mls group")
.expect("group exists");
let pre_update_pubkey = pre_update_mls_group
.own_leaf()
.expect("own leaf")
.signature_key()
.as_slice()
.to_vec();
mdk.self_update(&group_id).expect("self_update");
let pending_mls_group = mdk
.load_mls_group(&group_id)
.expect("load mls group")
.expect("group exists");
let new_pubkey = pending_mls_group
.pending_commit()
.expect("pending commit exists")
.update_path_leaf_node()
.expect("update path leaf node in self_update commit")
.signature_key()
.as_slice()
.to_vec();
assert_ne!(
pre_update_pubkey, new_pubkey,
"self_update should rotate the signature key"
);
let stored_before_merge = SignatureKeyPair::read(
&mdk.provider.storage,
&new_pubkey,
mdk.ciphersuite.signature_algorithm(),
);
assert!(
stored_before_merge.is_some(),
"new keypair must be in storage before merge_pending_commit"
);
mdk.merge_pending_commit(&group_id)
.expect("merge_pending_commit");
let stored_after_merge = SignatureKeyPair::read(
&mdk.provider.storage,
&new_pubkey,
mdk.ciphersuite.signature_algorithm(),
);
assert!(
stored_after_merge.is_some(),
"new keypair must remain in storage after successful merge (it is the active signer)"
);
let members_after = mdk.get_members(&group_id).expect("get members");
assert!(!members_after.is_empty(), "group should still have members");
}
#[test]
fn test_self_update_then_clear_removes_orphaned_keypair() {
let mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&mdk, &creator, &members, &admins);
mdk.self_update(&group_id).expect("self_update");
let pending_mls_group = mdk
.load_mls_group(&group_id)
.expect("load mls group")
.expect("group exists");
let new_pubkey = pending_mls_group
.pending_commit()
.expect("pending commit exists")
.update_path_leaf_node()
.expect("update path leaf node in self_update commit")
.signature_key()
.as_slice()
.to_vec();
let stored_before_clear = SignatureKeyPair::read(
&mdk.provider.storage,
&new_pubkey,
mdk.ciphersuite.signature_algorithm(),
);
assert!(
stored_before_clear.is_some(),
"new keypair must be in storage before clear_pending_commit"
);
mdk.clear_pending_commit(&group_id)
.expect("clear_pending_commit");
let stored_after_clear = SignatureKeyPair::read(
&mdk.provider.storage,
&new_pubkey,
mdk.ciphersuite.signature_algorithm(),
);
assert!(
stored_after_clear.is_none(),
"orphaned new keypair must be deleted from storage after clear_pending_commit"
);
mdk.self_update(&group_id)
.expect("self_update should succeed after clearing pending commit");
mdk.merge_pending_commit(&group_id)
.expect("merge should succeed after retry");
let final_mls_group = mdk
.load_mls_group(&group_id)
.expect("load mls group")
.expect("group exists");
let final_pubkey = final_mls_group
.own_leaf()
.expect("own leaf")
.signature_key()
.as_slice()
.to_vec();
let active_signer = SignatureKeyPair::read(
&mdk.provider.storage,
&final_pubkey,
mdk.ciphersuite.signature_algorithm(),
);
assert!(
active_signer.is_some(),
"active signer keypair must be present in storage after successful retry"
);
}
#[test]
fn test_create_group_with_invalid_invitee_kp_does_not_persist_signer() {
let creator_mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let good_kp = create_key_package_event(&creator_mdk, &members[0]);
let impostor = Keys::generate();
let malformed_kp =
create_key_package_event_with_key(&creator_mdk, &members[1].public_key(), &impostor);
let signers_before = creator_mdk.provider.storage.signature_key_count();
let result = creator_mdk.create_group(
&creator_pk,
vec![good_kp, malformed_kp],
create_nostr_group_config_data(admins),
);
assert!(
result.is_err(),
"create_group must reject a malformed invitee KeyPackage"
);
let signers_after = creator_mdk.provider.storage.signature_key_count();
assert_eq!(
signers_before, signers_after,
"failed create_group must not persist a signer (orphan signer leaked)"
);
}
#[test]
fn test_get_ratchet_tree_info() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id;
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let debug_info = creator_mdk
.get_ratchet_tree_info(group_id)
.expect("Failed to get ratchet tree info");
assert_eq!(debug_info.tree_hash.len(), 64);
assert!(
debug_info.tree_hash.chars().all(|c| c.is_ascii_hexdigit()),
"tree_hash should be valid hex"
);
assert!(
!debug_info.serialized_tree.is_empty(),
"serialized tree should not be empty"
);
assert!(
debug_info
.serialized_tree
.chars()
.all(|c| c.is_ascii_hexdigit()),
"serialized tree should be valid hex"
);
assert_eq!(
debug_info.leaf_nodes.len(),
3,
"should have 3 leaf nodes (creator + 2 members)"
);
for leaf in &debug_info.leaf_nodes {
assert!(
!leaf.encryption_key.is_empty(),
"encryption key should not be empty"
);
assert!(
!leaf.signature_key.is_empty(),
"signature key should not be empty"
);
assert_eq!(
leaf.credential_identity.len(),
64,
"credential identity should be a 32-byte hex pubkey"
);
}
let creator_hex = creator_pk.to_hex();
assert!(
debug_info
.leaf_nodes
.iter()
.any(|l| l.credential_identity == creator_hex),
"creator pubkey should be in leaf nodes"
);
}
#[test]
fn test_get_ratchet_tree_info_nonexistent_group() {
let mdk = create_test_mdk();
let fake_group_id = mdk_storage_traits::GroupId::from_slice(&[0u8; 32]);
let result = mdk.get_ratchet_tree_info(&fake_group_id);
assert!(result.is_err(), "should error for nonexistent group");
}
#[test]
fn test_get_ratchet_tree_info_deterministic() {
let creator_mdk = create_test_mdk();
let (creator, initial_members, admins) = create_test_group_members();
let creator_pk = creator.public_key();
let mut initial_key_package_events = Vec::new();
for member_keys in &initial_members {
let key_package_event = create_key_package_event(&creator_mdk, member_keys);
initial_key_package_events.push(key_package_event);
}
let create_result = creator_mdk
.create_group(
&creator_pk,
initial_key_package_events,
create_nostr_group_config_data(admins),
)
.expect("Failed to create group");
let group_id = &create_result.group.mls_group_id;
creator_mdk
.merge_pending_commit(group_id)
.expect("Failed to merge pending commit");
let info1 = creator_mdk
.get_ratchet_tree_info(group_id)
.expect("first call");
let info2 = creator_mdk
.get_ratchet_tree_info(group_id)
.expect("second call");
assert_eq!(info1, info2, "ratchet tree info should be deterministic");
}
#[test]
fn test_own_leaf_index_and_group_leaf_map() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let bob_key_package = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_key_package],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
let bob_welcome_rumor = &create_result.welcome_rumors[0];
let bob_welcome = bob_mdk
.process_welcome(&nostr::EventId::all_zeros(), bob_welcome_rumor)
.expect("Bob should process welcome");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
bob_mdk
.accept_welcome(&bob_welcome)
.expect("Bob should accept welcome");
assert_eq!(alice_mdk.own_leaf_index(&group_id).unwrap(), 0);
assert_eq!(bob_mdk.own_leaf_index(&group_id).unwrap(), 1);
let leaf_map = alice_mdk.group_leaf_map(&group_id).unwrap();
assert_eq!(leaf_map.get(&0), Some(&alice_keys.public_key()));
assert_eq!(leaf_map.get(&1), Some(&bob_keys.public_key()));
}
#[test]
fn test_group_leaf_map_preserves_tree_holes() {
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let dave_keys = Keys::generate();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let dave_mdk = create_test_mdk();
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![
create_key_package_event(&bob_mdk, &bob_keys),
create_key_package_event(&charlie_mdk, &charlie_keys),
create_key_package_event(&dave_mdk, &dave_keys),
],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("Alice should create group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("Alice should merge commit");
alice_mdk
.remove_members(&group_id, &[charlie_keys.public_key()])
.expect("Should remove Charlie");
alice_mdk
.merge_pending_commit(&group_id)
.expect("Should merge Charlie removal");
let leaf_map = alice_mdk.group_leaf_map(&group_id).unwrap();
assert_eq!(leaf_map.get(&0), Some(&alice_keys.public_key()));
assert_eq!(leaf_map.get(&1), Some(&bob_keys.public_key()));
assert_eq!(leaf_map.get(&3), Some(&dave_keys.public_key()));
assert!(!leaf_map.contains_key(&2));
}
#[test]
fn test_create_group_all_modern_invitees_requires_self_remove() {
let creator_mdk = create_test_mdk();
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let creator = Keys::generate();
let alice = Keys::generate();
let bob = Keys::generate();
let creator_pk = creator.public_key();
let alice_kp = create_key_package_event(&alice_mdk, &alice);
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let create_result = creator_mdk
.create_group(
&creator_pk,
vec![alice_kp, bob_kp],
create_nostr_group_config_data(vec![creator_pk]),
)
.expect("all-modern group creation should succeed");
let group_id = create_result.group.mls_group_id.clone();
let mls_group = creator_mdk
.load_mls_group(&group_id)
.expect("load ok")
.expect("group exists");
let proposal_types: Vec<ProposalType> = mls_group
.extensions()
.required_capabilities()
.map(|rc| rc.proposal_types().to_vec())
.unwrap_or_default();
assert!(
proposal_types.contains(&ProposalType::SelfRemove),
"all-modern group should require SelfRemove (got {:?})",
proposal_types
);
}
#[test]
fn test_create_group_empty_invitees_yields_empty_required_capabilities() {
let creator_mdk = create_test_mdk();
let creator = Keys::generate();
let creator_pk = creator.public_key();
let create_result = creator_mdk
.create_group(
&creator_pk,
Vec::new(),
create_nostr_group_config_data(vec![creator_pk]),
)
.expect("single-member group creation should succeed");
let group_id = create_result.group.mls_group_id.clone();
let mls_group = creator_mdk
.load_mls_group(&group_id)
.expect("load ok")
.expect("group exists");
let proposal_types = mls_group
.extensions()
.required_capabilities()
.map(|rc| rc.proposal_types().to_vec())
.unwrap_or_default();
assert!(
proposal_types.is_empty(),
"empty-invitee group must stay permissive (got {:?})",
proposal_types
);
}
#[test]
fn test_group_required_proposals_all_modern() {
use crate::constant::SUPPORTED_PROPOSALS;
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_kp = create_key_package_event(&charlie_mdk, &charlie_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_kp, charlie_kp],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("alice creates all-modern group");
let group_id = create_result.group.mls_group_id;
let expected: BTreeSet<_> = SUPPORTED_PROPOSALS.iter().copied().collect();
assert_eq!(
alice_mdk
.group_required_proposals(&group_id)
.expect("accessor succeeds"),
expected,
);
}
#[test]
fn test_group_required_proposals_mixed_is_empty() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let legacy_keys = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob_keys);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("alice creates mixed group");
let group_id = create_result.group.mls_group_id;
assert!(
alice_mdk
.group_required_proposals(&group_id)
.expect("accessor succeeds")
.is_empty(),
"mixed-invitee group must yield empty LCD"
);
}
#[test]
fn test_group_required_proposals_missing_group() {
let mdk = create_test_mdk();
let fabricated = crate::GroupId::from_slice(&[1, 2, 3, 4, 5]);
let err = mdk
.group_required_proposals(&fabricated)
.expect_err("missing group must error");
assert!(
matches!(err, Error::GroupNotFound),
"expected Error::GroupNotFound, got {:?}",
err
);
}
#[test]
fn test_group_required_proposals_survives_self_update() {
use crate::constant::SUPPORTED_PROPOSALS;
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_kp],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("alice creates all-modern group");
let group_id = create_result.group.mls_group_id;
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation");
alice_mdk
.self_update(&group_id)
.expect("alice rotates her leaf");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges the rotation");
let expected: BTreeSet<_> = SUPPORTED_PROPOSALS.iter().copied().collect();
assert_eq!(
alice_mdk
.group_required_proposals(&group_id)
.expect("accessor succeeds post-rotation"),
expected,
"self_update must not change required capabilities"
);
}
#[test]
fn test_group_member_capabilities_reports_per_member() {
use crate::groups::MemberCapabilities;
let creator_mdk = create_test_mdk();
let alice_mdk = create_test_mdk();
let creator = Keys::generate();
let alice = Keys::generate();
let legacy = Keys::generate();
let alice_kp = create_key_package_event(&alice_mdk, &alice);
let legacy_kp = create_legacy_key_package_event(&creator_mdk, &legacy);
let create_result = creator_mdk
.create_group(
&creator.public_key(),
vec![alice_kp, legacy_kp],
create_nostr_group_config_data(vec![creator.public_key()]),
)
.expect("create group");
let group_id = create_result.group.mls_group_id;
let roster: Vec<MemberCapabilities> = creator_mdk
.group_member_capabilities(&group_id)
.expect("roster");
assert_eq!(roster.len(), 3, "three live leaves expected");
let entry_for = |pk: &PublicKey| -> &MemberCapabilities {
roster.iter().find(|m| m.member == *pk).unwrap_or_else(|| {
panic!("no roster entry for {pk:?}");
})
};
let creator_entry = entry_for(&creator.public_key());
assert!(creator_entry.is_admin, "creator is admin");
assert!(
creator_entry.proposals.contains(&ProposalType::SelfRemove),
"modern creator advertises SelfRemove"
);
let alice_entry = entry_for(&alice.public_key());
assert!(!alice_entry.is_admin, "alice is non-admin");
assert!(
alice_entry.proposals.contains(&ProposalType::SelfRemove),
"modern alice advertises SelfRemove"
);
let legacy_entry = entry_for(&legacy.public_key());
assert!(!legacy_entry.is_admin, "legacy is non-admin");
assert!(
!legacy_entry.proposals.contains(&ProposalType::SelfRemove),
"capability-poor legacy leaf does not advertise SelfRemove"
);
}
#[test]
fn test_group_member_capabilities_is_callable_by_non_admins() {
let creator_mdk = create_test_mdk();
let alice_mdk = create_test_mdk();
let creator = Keys::generate();
let alice = Keys::generate();
let alice_kp = create_key_package_event(&alice_mdk, &alice);
let create_result = creator_mdk
.create_group(
&creator.public_key(),
vec![alice_kp],
create_nostr_group_config_data(vec![creator.public_key()]),
)
.expect("create group");
let group_id = create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(&group_id)
.expect("creator merges");
let alice_welcome = alice_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("alice processes welcome");
alice_mdk
.accept_welcome(&alice_welcome)
.expect("alice accepts");
let roster = alice_mdk
.group_member_capabilities(&group_id)
.expect("non-admin may read");
assert_eq!(roster.len(), 2);
}
#[test]
fn test_upgrade_status_all_required_for_all_modern_group() {
use crate::constant::SUPPORTED_PROPOSALS;
let creator_mdk = create_test_mdk();
let alice_mdk = create_test_mdk();
let creator = Keys::generate();
let alice = Keys::generate();
let alice_kp = create_key_package_event(&alice_mdk, &alice);
let create_result = creator_mdk
.create_group(
&creator.public_key(),
vec![alice_kp],
create_nostr_group_config_data(vec![creator.public_key()]),
)
.expect("all-modern group creation");
let group_id = create_result.group.mls_group_id;
let status = creator_mdk
.group_capability_upgrade_status(&group_id)
.expect("status call succeeds");
assert_eq!(
status.per_proposal.len(),
SUPPORTED_PROPOSALS.len(),
"one entry per SUPPORTED_PROPOSALS"
);
assert!(
!status.per_proposal.is_empty(),
"SUPPORTED_PROPOSALS unexpectedly empty"
);
let self_remove_entry = status
.per_proposal
.iter()
.find(|(pt, _)| *pt == ProposalType::SelfRemove)
.expect("SelfRemove entry present");
assert!(
matches!(self_remove_entry.1, ProposalUpgradability::AlreadyRequired),
"SelfRemove should already be required, got {:?}",
self_remove_entry.1
);
for (_, upgradability) in &status.per_proposal {
assert!(
matches!(upgradability, ProposalUpgradability::AlreadyRequired),
"all-modern group should report AlreadyRequired, got {upgradability:?}"
);
}
}
#[test]
fn test_upgrade_status_available_after_legacy_member_leaves() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let legacy = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice.public_key()]),
)
.expect("mixed group creation");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob processes welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
let remove_result = alice_mdk
.remove_members(&group_id, &[legacy.public_key()])
.expect("alice removes legacy");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges removal");
bob_mdk
.process_message(&remove_result.evolution_event)
.expect("bob processes removal commit");
let status = alice_mdk
.group_capability_upgrade_status(&group_id)
.expect("status call succeeds");
let self_remove_entry = status
.per_proposal
.iter()
.find(|(pt, _)| *pt == ProposalType::SelfRemove)
.expect("SelfRemove entry present");
assert!(
matches!(self_remove_entry.1, ProposalUpgradability::Available),
"formerly-mixed group should now be Available, got {:?}",
self_remove_entry.1
);
}
#[test]
fn test_upgrade_status_blocked_names_blocker() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let legacy = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice.public_key()]),
)
.expect("mixed group");
let group_id = create_result.group.mls_group_id;
let status = alice_mdk
.group_capability_upgrade_status(&group_id)
.expect("status call succeeds");
let self_remove_entry = status
.per_proposal
.iter()
.find(|(pt, _)| *pt == ProposalType::SelfRemove)
.expect("SelfRemove entry present");
match &self_remove_entry.1 {
ProposalUpgradability::Blocked { blockers } => {
assert_eq!(
blockers,
&vec![legacy.public_key()],
"legacy member is the sole blocker"
);
}
other => panic!("expected Blocked, got {other:?}"),
}
}
#[test]
fn test_upgrade_status_is_callable_by_non_admins() {
let creator_mdk = create_test_mdk();
let alice_mdk = create_test_mdk();
let creator = Keys::generate();
let alice = Keys::generate();
let alice_kp = create_key_package_event(&alice_mdk, &alice);
let create_result = creator_mdk
.create_group(
&creator.public_key(),
vec![alice_kp],
create_nostr_group_config_data(vec![creator.public_key()]),
)
.expect("create");
let group_id = create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(&group_id)
.expect("creator merges");
let alice_welcome = alice_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("alice welcome");
alice_mdk
.accept_welcome(&alice_welcome)
.expect("alice accepts");
alice_mdk
.group_capability_upgrade_status(&group_id)
.expect("non-admin may call status");
}
#[test]
fn test_upgrade_group_capabilities_happy_path() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let legacy = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice.public_key()]),
)
.expect("mixed creation");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
let remove_result = alice_mdk
.remove_members(&group_id, &[legacy.public_key()])
.expect("alice removes legacy");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges removal");
bob_mdk
.process_message(&remove_result.evolution_event)
.expect("bob processes removal");
let pre_admins = NostrGroupDataExtension::from_group(
&alice_mdk.load_mls_group(&group_id).unwrap().unwrap(),
)
.unwrap()
.admins
.len();
let upgrade_result = alice_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove]))
.expect("admin upgrade succeeds");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges upgrade");
bob_mdk
.process_message(&upgrade_result.evolution_event)
.expect("bob processes upgrade");
let post_admins = NostrGroupDataExtension::from_group(
&alice_mdk.load_mls_group(&group_id).unwrap().unwrap(),
)
.unwrap()
.admins
.len();
assert_eq!(pre_admins, post_admins, "admin count unchanged");
assert!(
alice_mdk
.group_required_proposals(&group_id)
.unwrap()
.contains(&ProposalType::SelfRemove),
"alice sees SelfRemove now required"
);
assert!(
bob_mdk
.group_required_proposals(&group_id)
.unwrap()
.contains(&ProposalType::SelfRemove),
"bob sees SelfRemove now required"
);
let bob_leave = bob_mdk.leave_group(&group_id).expect("bob leaves modern");
let observed = alice_mdk
.process_message(&bob_leave.evolution_event)
.expect("alice processes bob's leave");
assert!(
matches!(observed, MessageProcessingResult::Proposal { .. }),
"post-upgrade leave must be modern SelfRemove (Proposal auto-commit), got {observed:?}"
);
}
#[test]
fn test_upgrade_group_capabilities_converges_wire_format_to_mixed_ciphertext() {
use openmls::prelude::{
MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY, MlsGroupJoinConfig,
PURE_CIPHERTEXT_WIRE_FORMAT_POLICY,
};
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let legacy = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice.public_key()]),
)
.expect("mixed creation");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
let remove_result = alice_mdk
.remove_members(&group_id, &[legacy.public_key()])
.expect("alice removes legacy");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges removal");
bob_mdk
.process_message(&remove_result.evolution_event)
.expect("bob processes removal");
let mut mls_group = alice_mdk
.load_mls_group(&group_id)
.expect("load for setup")
.expect("group exists");
let pure_config = MlsGroupJoinConfig::builder()
.wire_format_policy(PURE_CIPHERTEXT_WIRE_FORMAT_POLICY)
.build();
mls_group
.set_configuration(&alice_mdk.provider.storage, &pure_config)
.expect("force PURE wire format");
drop(mls_group);
let pre = alice_mdk
.load_mls_group(&group_id)
.expect("load pre")
.expect("group pre");
assert_eq!(
pre.configuration().wire_format_policy(),
PURE_CIPHERTEXT_WIRE_FORMAT_POLICY,
"sanity: upgrader starts on stale PURE wire format"
);
drop(pre);
alice_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove]))
.expect("admin upgrade succeeds");
let post = alice_mdk
.load_mls_group(&group_id)
.expect("load post")
.expect("group post");
assert_eq!(
post.configuration().wire_format_policy(),
MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY,
"upgrade_group_capabilities should converge wire format to MIXED_CIPHERTEXT"
);
}
#[test]
fn test_upgrade_group_capabilities_rejects_empty_set() {
let alice_mdk = create_test_mdk();
let alice = Keys::generate();
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![],
create_nostr_group_config_data(vec![alice.public_key()]),
)
.expect("single-member group");
let group_id = create_result.group.mls_group_id;
assert!(matches!(
alice_mdk.upgrade_group_capabilities(&group_id, &BTreeSet::new()),
Err(Error::EmptyUpgradeSet)
));
}
#[test]
fn test_upgrade_group_capabilities_rejects_non_admin() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let legacy = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice.public_key()]),
)
.expect("mixed");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("accept");
let remove = alice_mdk
.remove_members(&group_id, &[legacy.public_key()])
.expect("remove");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges removal");
bob_mdk
.process_message(&remove.evolution_event)
.expect("bob processes removal");
assert!(matches!(
bob_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove])),
Err(Error::NotAdmin)
));
assert!(
!bob_mdk
.group_required_proposals(&group_id)
.unwrap()
.contains(&ProposalType::SelfRemove),
"group still permissive"
);
}
#[test]
fn test_upgrade_group_capabilities_surfaces_blocker_identities() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let legacy = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice.public_key()]),
)
.expect("mixed");
let group_id = create_result.group.mls_group_id;
match alice_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove]))
{
Err(Error::ProposalNotAvailableForUpgrade { proposal, blockers }) => {
assert_eq!(proposal, ProposalType::SelfRemove);
assert_eq!(blockers, vec![legacy.public_key()]);
}
other => panic!("expected ProposalNotAvailableForUpgrade, got {other:?}"),
}
}
#[test]
fn test_upgrade_group_capabilities_rejects_already_required() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp],
create_nostr_group_config_data(vec![alice.public_key()]),
)
.expect("all-modern");
let group_id = create_result.group.mls_group_id;
assert!(matches!(
alice_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove])),
Err(Error::ProposalAlreadyRequired(ProposalType::SelfRemove))
));
}
#[test]
fn test_upgrade_group_capabilities_toctou_already_required() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let legacy = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice.public_key(), bob.public_key()]),
)
.expect("mixed group with two admins");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
let remove = alice_mdk
.remove_members(&group_id, &[legacy.public_key()])
.expect("remove legacy");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges removal");
bob_mdk
.process_message(&remove.evolution_event)
.expect("bob processes removal");
let b_result = bob_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove]))
.expect("bob upgrades");
bob_mdk
.merge_pending_commit(&group_id)
.expect("bob merges upgrade");
alice_mdk
.process_message(&b_result.evolution_event)
.expect("alice processes bob's upgrade");
assert!(matches!(
alice_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove])),
Err(Error::ProposalAlreadyRequired(ProposalType::SelfRemove))
));
}
#[test]
fn test_upgrade_group_capabilities_concurrent_admins_converge() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let alice = Keys::generate();
let bob = Keys::generate();
let legacy = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy);
let create_result = alice_mdk
.create_group(
&alice.public_key(),
vec![bob_kp, legacy_kp],
create_nostr_group_config_data(vec![alice.public_key(), bob.public_key()]),
)
.expect("mixed group with two admins");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
let remove = alice_mdk
.remove_members(&group_id, &[legacy.public_key()])
.expect("remove");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges removal");
bob_mdk
.process_message(&remove.evolution_event)
.expect("bob processes removal");
let alice_upgrade = alice_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove]))
.expect("alice proposes upgrade");
let _bob_upgrade = bob_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove]))
.expect("bob proposes upgrade");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges her upgrade");
bob_mdk
.process_message(&alice_upgrade.evolution_event)
.expect("bob adopts alice's upgrade commit");
let bob_group = bob_mdk
.load_mls_group(&group_id)
.expect("bob group load")
.expect("bob group exists");
assert!(
bob_group.pending_commit().is_none(),
"bob's losing pending commit should be cleared after adopting alice's commit"
);
let alice_required = alice_mdk.group_required_proposals(&group_id).unwrap();
let bob_required = bob_mdk.group_required_proposals(&group_id).unwrap();
assert!(alice_required.contains(&ProposalType::SelfRemove));
assert!(bob_required.contains(&ProposalType::SelfRemove));
let alice_count = alice_required
.iter()
.filter(|p| **p == ProposalType::SelfRemove)
.count();
assert_eq!(
alice_count, 1,
"exactly one SelfRemove entry after convergence"
);
}
#[test]
fn test_admin_in_mixed_group_demotes_then_leaves_legacy() {
let alice_mdk = create_test_mdk(); let bob_mdk = create_test_mdk(); let charlie_mdk = create_test_mdk(); let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let legacy_keys = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_kp = create_key_package_event(&charlie_mdk, &charlie_keys);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_kp, charlie_kp, legacy_kp],
create_nostr_group_config_data(vec![
alice_keys.public_key(),
bob_keys.public_key(),
]),
)
.expect("alice creates mixed group with two admins");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob processes welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
let charlie_welcome = charlie_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[1],
)
.expect("charlie processes welcome");
charlie_mdk
.accept_welcome(&charlie_welcome)
.expect("charlie accepts");
let direct_err = alice_mdk
.leave_group(&group_id)
.expect_err("admin cannot leave without demoting");
assert!(
direct_err.to_string().contains("self-demote"),
"expected demotion-required error substring; got: {direct_err}"
);
let demote_result = alice_mdk
.self_demote(&group_id)
.expect("alice self-demotes");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges her demotion");
charlie_mdk
.process_message(&demote_result.evolution_event)
.expect("charlie processes demotion commit");
let alice_leave = alice_mdk
.leave_group(&group_id)
.expect("alice leaves after demotion");
let result = charlie_mdk
.process_message(&alice_leave.evolution_event)
.expect("charlie processes alice's leave");
assert!(
matches!(result, MessageProcessingResult::PendingProposal { .. }),
"demoted-admin leave in a mixed group should fall back to legacy Remove \
(non-admin receiver sees PendingProposal); got {:?}",
result
);
}
#[test]
fn test_self_update_refreshes_leaf_capabilities() {
let alice_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let alice_keys = Keys::generate();
let charlie_keys = Keys::generate();
let legacy_kp = create_legacy_key_package_event(&charlie_mdk, &charlie_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![legacy_kp],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("alice creates mixed group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges");
let charlie_welcome = charlie_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("charlie processes welcome");
charlie_mdk
.accept_welcome(&charlie_welcome)
.expect("charlie accepts");
let pre = charlie_mdk
.load_mls_group(&group_id)
.expect("load pre")
.expect("group pre");
let pre_proposals: Vec<ProposalType> = pre
.own_leaf()
.expect("own leaf pre")
.capabilities()
.proposals()
.to_vec();
assert!(
pre_proposals.is_empty(),
"sanity: capability-poor leaf starts with NO proposals; got {:?}",
pre_proposals
);
charlie_mdk
.self_update(&group_id)
.expect("self-update succeeds");
charlie_mdk
.merge_pending_commit(&group_id)
.expect("charlie merges self-update");
let post = charlie_mdk
.load_mls_group(&group_id)
.expect("load post")
.expect("group post");
let post_proposals: Vec<ProposalType> = post
.own_leaf()
.expect("own leaf post")
.capabilities()
.proposals()
.to_vec();
assert!(
post_proposals.contains(&ProposalType::SelfRemove),
"self_update should refresh leaf proposals to current MDK capabilities; \
expected SelfRemove, got {:?}",
post_proposals
);
}
#[test]
fn test_self_update_converges_wire_format_to_mixed_ciphertext() {
use openmls::prelude::{
MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY, MlsGroupJoinConfig,
PURE_CIPHERTEXT_WIRE_FORMAT_POLICY,
};
let mdk = create_test_mdk();
let (creator, members, admins) = create_test_group_members();
let group_id = create_test_group(&mdk, &creator, &members, &admins);
let mut mls_group = mdk
.load_mls_group(&group_id)
.expect("load for setup")
.expect("group exists");
let pure_config = MlsGroupJoinConfig::builder()
.wire_format_policy(PURE_CIPHERTEXT_WIRE_FORMAT_POLICY)
.build();
mls_group
.set_configuration(&mdk.provider.storage, &pure_config)
.expect("force PURE wire format");
drop(mls_group);
let pre = mdk
.load_mls_group(&group_id)
.expect("load pre")
.expect("group pre");
assert_eq!(
pre.configuration().wire_format_policy(),
PURE_CIPHERTEXT_WIRE_FORMAT_POLICY,
"sanity: member starts on stale PURE wire format"
);
drop(pre);
mdk.self_update(&group_id).expect("self_update");
let post = mdk
.load_mls_group(&group_id)
.expect("load post")
.expect("group post");
assert_eq!(
post.configuration().wire_format_policy(),
MIXED_CIPHERTEXT_WIRE_FORMAT_POLICY,
"self_update should converge wire format to MIXED_CIPHERTEXT"
);
}
#[test]
fn test_formerly_mixed_group_can_be_upgraded_after_legacy_members_leave() {
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let legacy_keys = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_kp = create_key_package_event(&charlie_mdk, &charlie_keys);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_kp, charlie_kp, legacy_kp],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("mixed group creation");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob processes welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
let charlie_welcome = charlie_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[1],
)
.expect("charlie processes welcome");
charlie_mdk
.accept_welcome(&charlie_welcome)
.expect("charlie accepts");
let remove = alice_mdk
.remove_members(&group_id, &[legacy_keys.public_key()])
.expect("alice removes legacy");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges removal");
bob_mdk
.process_message(&remove.evolution_event)
.expect("bob processes removal");
charlie_mdk
.process_message(&remove.evolution_event)
.expect("charlie processes removal");
let upgrade = alice_mdk
.upgrade_group_capabilities(&group_id, &BTreeSet::from([ProposalType::SelfRemove]))
.expect("alice upgrades");
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges upgrade");
bob_mdk
.process_message(&upgrade.evolution_event)
.expect("bob processes upgrade");
charlie_mdk
.process_message(&upgrade.evolution_event)
.expect("charlie processes upgrade");
for mdk in [&alice_mdk, &bob_mdk, &charlie_mdk] {
assert!(
mdk.group_required_proposals(&group_id)
.unwrap()
.contains(&ProposalType::SelfRemove),
"peer sees SelfRemove in RequiredCapabilities post-upgrade"
);
}
let bob_leave = bob_mdk.leave_group(&group_id).expect("bob leaves");
let observed = charlie_mdk
.process_message(&bob_leave.evolution_event)
.expect("charlie processes bob's leave");
assert!(
matches!(observed, MessageProcessingResult::Proposal { .. }),
"post-upgrade leave uses modern SelfRemove (Proposal auto-commit), got {observed:?}"
);
}
#[test]
fn test_self_remove_restores_ciphertext_config_on_disk() {
use openmls::prelude::{IncomingWireFormatPolicy, OutgoingWireFormatPolicy};
let alice_mdk = create_test_mdk();
let bob_mdk = create_test_mdk();
let charlie_mdk = create_test_mdk();
let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let bob_kp = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_kp = create_key_package_event(&charlie_mdk, &charlie_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_kp, charlie_kp],
create_nostr_group_config_data(vec![alice_keys.public_key()]),
)
.expect("alice creates group");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation commit");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob processes welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
bob_mdk
.leave_group(&group_id)
.expect("bob sends SelfRemove");
let reloaded = bob_mdk
.load_mls_group(&group_id)
.expect("reload ok")
.expect("group still exists for bob");
let policy = reloaded.configuration().wire_format_policy();
assert_eq!(
policy.outgoing(),
OutgoingWireFormatPolicy::AlwaysCiphertext,
"outgoing policy should be restored to AlwaysCiphertext"
);
assert_eq!(
policy.incoming(),
IncomingWireFormatPolicy::Mixed,
"incoming policy should be restored to Mixed (MIXED_CIPHERTEXT)"
);
}
#[test]
fn test_leave_mixed_group_falls_back_to_legacy_remove() {
let alice_mdk = create_test_mdk(); let bob_mdk = create_test_mdk(); let charlie_mdk = create_test_mdk(); let alice_keys = Keys::generate();
let bob_keys = Keys::generate();
let charlie_keys = Keys::generate();
let legacy_keys = Keys::generate();
let admins = vec![alice_keys.public_key()];
let bob_kp = create_key_package_event(&bob_mdk, &bob_keys);
let charlie_kp = create_key_package_event(&charlie_mdk, &charlie_keys);
let legacy_kp = create_legacy_key_package_event(&alice_mdk, &legacy_keys);
let create_result = alice_mdk
.create_group(
&alice_keys.public_key(),
vec![bob_kp, charlie_kp, legacy_kp],
create_nostr_group_config_data(admins),
)
.expect("mixed group creation should succeed");
let group_id = create_result.group.mls_group_id.clone();
alice_mdk
.merge_pending_commit(&group_id)
.expect("alice merges creation commit");
let bob_welcome = bob_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[0],
)
.expect("bob processes welcome");
bob_mdk.accept_welcome(&bob_welcome).expect("bob accepts");
let charlie_welcome = charlie_mdk
.process_welcome(
&nostr::EventId::all_zeros(),
&create_result.welcome_rumors[1],
)
.expect("charlie processes welcome");
charlie_mdk
.accept_welcome(&charlie_welcome)
.expect("charlie accepts");
let bob_leave = bob_mdk.leave_group(&group_id).expect("Bob should leave");
let result = charlie_mdk
.process_message(&bob_leave.evolution_event)
.expect("charlie processes bob's leave");
assert!(
matches!(result, MessageProcessingResult::PendingProposal { .. }),
"mixed-group leave must fall back to legacy Remove and surface as PendingProposal \
for a non-admin receiver; got {:?}",
result
);
}
#[test]
fn test_add_members_rejects_legacy_kp_in_all_modern_group() {
let creator_mdk = create_test_mdk();
let alice_mdk = create_test_mdk();
let legacy_mdk = create_test_mdk();
let creator = Keys::generate();
let alice = Keys::generate();
let legacy = Keys::generate();
let creator_pk = creator.public_key();
let alice_kp = create_key_package_event(&alice_mdk, &alice);
let create_result = creator_mdk
.create_group(
&creator_pk,
vec![alice_kp],
create_nostr_group_config_data(vec![creator_pk]),
)
.expect("all-modern group creation should succeed");
let group_id = create_result.group.mls_group_id.clone();
creator_mdk
.merge_pending_commit(&group_id)
.expect("merge creation commit");
let legacy_kp = create_legacy_key_package_event(&legacy_mdk, &legacy);
let err = creator_mdk
.add_members(&group_id, &[legacy_kp])
.expect_err("adding capability-poor KP to all-modern group must fail");
assert!(
matches!(err, Error::InviteeMissingRequiredProposal),
"expected typed InviteeMissingRequiredProposal; got: {err:?}"
);
}
#[test]
fn test_create_group_all_legacy_invitees_yields_empty_required_capabilities() {
let creator_mdk = create_test_mdk();
let creator = Keys::generate();
let legacy1 = Keys::generate();
let legacy2 = Keys::generate();
let creator_pk = creator.public_key();
let kp1 = create_legacy_key_package_event(&creator_mdk, &legacy1);
let kp2 = create_legacy_key_package_event(&creator_mdk, &legacy2);
let create_result = creator_mdk
.create_group(
&creator_pk,
vec![kp1, kp2],
create_nostr_group_config_data(vec![creator_pk]),
)
.expect("all-legacy group creation should succeed");
let group_id = create_result.group.mls_group_id.clone();
let mls_group = creator_mdk
.load_mls_group(&group_id)
.expect("load ok")
.expect("group exists");
let proposal_types = mls_group
.extensions()
.required_capabilities()
.map(|rc| rc.proposal_types().to_vec())
.unwrap_or_default();
assert!(
proposal_types.is_empty(),
"all-legacy group should have empty RequiredCapabilities.proposal_types (got {:?})",
proposal_types
);
}
#[test]
fn test_create_group_mixed_invitees_drops_self_remove_requirement() {
let creator_mdk = create_test_mdk();
let modern_mdk = create_test_mdk();
let creator = Keys::generate();
let modern_member = Keys::generate();
let legacy_member = Keys::generate();
let creator_pk = creator.public_key();
let modern_kp = create_key_package_event(&modern_mdk, &modern_member);
let legacy_kp = create_legacy_key_package_event(&creator_mdk, &legacy_member);
let create_result = creator_mdk
.create_group(
&creator_pk,
vec![modern_kp, legacy_kp],
create_nostr_group_config_data(vec![creator_pk]),
)
.expect("mixed-invitee group creation should succeed");
let group_id = create_result.group.mls_group_id.clone();
let mls_group = creator_mdk
.load_mls_group(&group_id)
.expect("load ok")
.expect("group exists");
let proposal_types: Vec<ProposalType> = mls_group
.extensions()
.required_capabilities()
.map(|rc| rc.proposal_types().to_vec())
.unwrap_or_default();
assert!(
!proposal_types.contains(&ProposalType::SelfRemove),
"mixed group must not require SelfRemove (got {:?})",
proposal_types
);
}
}