1use openmls::{
2 extensions::{Extension, Extensions, UnknownExtension},
3 group::GroupContext,
4};
5use prost::Message;
6use std::{collections::HashMap, fmt};
7use thiserror::Error;
8use xmtp_cryptography::Secret;
9use xmtp_proto::xmtp::mls::message_contents::{
10 GroupMutableMetadataV1 as GroupMutableMetadataProto, Inboxes as InboxesProto,
11};
12
13use super::group::{DMMetadataOptions, GroupMetadataOptions};
14use xmtp_configuration::{
15 DEFAULT_GROUP_DESCRIPTION, DEFAULT_GROUP_IMAGE_URL_SQUARE, DEFAULT_GROUP_NAME,
16 MUTABLE_METADATA_EXTENSION_ID,
17};
18
19#[derive(Debug, Error)]
21pub enum GroupMutableMetadataError {
22 #[error("serialization: {0}")]
23 Serialization(#[from] prost::EncodeError),
24 #[error("deserialization: {0}")]
25 Deserialization(#[from] prost::DecodeError),
26 #[error("missing extension")]
27 MissingExtension,
28 #[error("mutable extension updates only")]
29 NonMutableExtensionUpdate,
30 #[error("only one change per update permitted")]
31 TooManyUpdates,
32 #[error("no changes in this update")]
33 NoUpdates,
34 #[error("missing metadata field")]
35 MissingMetadataField,
36 #[error("malformed app-data component {component_id:?}: {reason}")]
49 MalformedComponent {
50 component_id: Option<super::app_data::component_id::ComponentId>,
53 reason: String,
55 },
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum MetadataField {
64 GroupName,
65 Description,
66 GroupImageUrlSquare,
67 MessageDisappearFromNS,
68 MessageDisappearInNS,
69 MinimumSupportedProtocolVersion,
70 CommitLogSigner,
71 AppData,
72}
73
74impl MetadataField {
75 pub const fn as_str(&self) -> &'static str {
77 match self {
78 MetadataField::GroupName => "group_name",
79 MetadataField::Description => "description",
80 MetadataField::GroupImageUrlSquare => "group_image_url_square",
81 MetadataField::MessageDisappearFromNS => "message_disappear_from_ns",
82 MetadataField::MessageDisappearInNS => "message_disappear_in_ns",
83 MetadataField::MinimumSupportedProtocolVersion => "minimum_supported_protocol_version",
84 MetadataField::CommitLogSigner => "_commit_log_signer",
86 MetadataField::AppData => "app_data",
87 }
88 }
89}
90
91impl fmt::Display for MetadataField {
92 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93 write!(f, "{}", self.as_str())
94 }
95}
96
97#[derive(Default, Debug, Copy, Clone, PartialEq)]
104pub struct MessageDisappearingSettings {
105 pub from_ns: i64,
106 pub in_ns: i64,
107}
108
109impl MessageDisappearingSettings {
110 pub fn new(from_ns: i64, in_ns: i64) -> Self {
111 Self { from_ns, in_ns }
112 }
113
114 pub fn is_enabled(&self) -> bool {
115 self.from_ns > 0 && self.in_ns > 0
116 }
117
118 pub fn expire_at_ns(&self, sent_at_ns: i64) -> Option<i64> {
123 (self.is_enabled() && sent_at_ns >= self.from_ns)
124 .then(|| sent_at_ns.saturating_add(self.in_ns))
125 }
126}
127
128#[derive(Debug, Clone, PartialEq)]
132pub struct GroupMutableMetadata {
133 pub attributes: HashMap<String, String>,
136 pub admin_list: Vec<String>,
139 pub super_admin_list: Vec<String>,
142}
143
144impl GroupMutableMetadata {
145 pub fn new(
147 attributes: HashMap<String, String>,
148 admin_list: Vec<String>,
149 super_admin_list: Vec<String>,
150 ) -> Self {
151 Self {
152 attributes,
153 admin_list,
154 super_admin_list,
155 }
156 }
157
158 pub fn new_default(
162 creator_inbox_id: String,
163 commit_log_signer: Option<Secret>,
164 opts: GroupMetadataOptions,
165 ) -> Self {
166 let mut attributes = HashMap::new();
167 attributes.insert(
168 MetadataField::GroupName.to_string(),
169 opts.name.unwrap_or_else(|| DEFAULT_GROUP_NAME.to_string()),
170 );
171 attributes.insert(
172 MetadataField::Description.to_string(),
173 opts.description
174 .unwrap_or_else(|| DEFAULT_GROUP_DESCRIPTION.to_string()),
175 );
176 attributes.insert(
177 MetadataField::GroupImageUrlSquare.to_string(),
178 opts.image_url_square
179 .unwrap_or_else(|| DEFAULT_GROUP_IMAGE_URL_SQUARE.to_string()),
180 );
181 attributes.insert(
182 MetadataField::AppData.to_string(),
183 opts.app_data.unwrap_or_default(),
184 );
185
186 if let Some(message_disappearing_settings) = opts.message_disappearing_settings {
187 attributes.insert(
188 MetadataField::MessageDisappearFromNS.to_string(),
189 message_disappearing_settings.from_ns.to_string(),
190 );
191 attributes.insert(
192 MetadataField::MessageDisappearInNS.to_string(),
193 message_disappearing_settings.in_ns.to_string(),
194 );
195 }
196
197 if let Some(signer) = commit_log_signer {
198 attributes.insert(
199 MetadataField::CommitLogSigner.to_string(),
200 hex::encode(signer.as_slice()),
201 );
202 }
203
204 let admin_list = vec![];
205 let super_admin_list = vec![creator_inbox_id.clone()];
206 Self {
207 attributes,
208 admin_list,
209 super_admin_list,
210 }
211 }
212
213 pub fn new_dm_default(
215 _creator_inbox_id: String,
216 _dm_target_inbox_id: &str,
217 commit_log_signer: Option<Secret>,
218 opts: DMMetadataOptions,
219 ) -> Self {
220 let mut attributes = HashMap::new();
221 attributes.insert(
223 MetadataField::GroupName.to_string(),
224 DEFAULT_GROUP_NAME.to_string(),
225 );
226 attributes.insert(
227 MetadataField::Description.to_string(),
228 DEFAULT_GROUP_DESCRIPTION.to_string(),
229 );
230 attributes.insert(
231 MetadataField::GroupImageUrlSquare.to_string(),
232 DEFAULT_GROUP_IMAGE_URL_SQUARE.to_string(),
233 );
234 if let Some(message_disappearing_settings) = opts.message_disappearing_settings {
235 attributes.insert(
236 MetadataField::MessageDisappearFromNS.to_string(),
237 message_disappearing_settings.from_ns.to_string(),
238 );
239 attributes.insert(
240 MetadataField::MessageDisappearInNS.to_string(),
241 message_disappearing_settings.in_ns.to_string(),
242 );
243 }
244
245 if let Some(signer) = commit_log_signer {
246 attributes.insert(
247 MetadataField::CommitLogSigner.to_string(),
248 hex::encode(signer.as_slice()),
249 );
250 }
251
252 let admin_list = vec![];
253 let super_admin_list = vec![];
254 Self {
255 attributes,
256 admin_list,
257 super_admin_list,
258 }
259 }
260
261 pub fn supported_fields() -> Vec<MetadataField> {
265 vec![
266 MetadataField::GroupName,
267 MetadataField::Description,
268 MetadataField::GroupImageUrlSquare,
269 MetadataField::MessageDisappearFromNS,
270 MetadataField::MessageDisappearInNS,
271 MetadataField::MinimumSupportedProtocolVersion,
272 MetadataField::AppData,
273 ]
274 }
275
276 pub fn is_admin(&self, inbox_id: &String) -> bool {
278 self.admin_list.contains(inbox_id)
279 }
280
281 pub fn is_super_admin(&self, inbox_id: &String) -> bool {
283 self.super_admin_list.contains(inbox_id)
284 }
285
286 pub fn commit_log_signer(&self) -> Option<Secret> {
289 self.attributes
290 .get(&MetadataField::CommitLogSigner.to_string())
291 .and_then(|hex_str| hex::decode(hex_str).ok())
292 .map(Secret::new)
293 }
294}
295
296impl TryFrom<GroupMutableMetadata> for Vec<u8> {
297 type Error = GroupMutableMetadataError;
298
299 fn try_from(value: GroupMutableMetadata) -> Result<Self, Self::Error> {
301 let mut buf = Vec::new();
302 let proto_val = GroupMutableMetadataProto {
303 attributes: value.attributes.clone(),
304 admin_list: Some(InboxesProto {
305 inbox_ids: value.admin_list,
306 }),
307 super_admin_list: Some(InboxesProto {
308 inbox_ids: value.super_admin_list,
309 }),
310 };
311 proto_val.encode(&mut buf)?;
312
313 Ok(buf)
314 }
315}
316
317impl TryFrom<&Vec<u8>> for GroupMutableMetadata {
318 type Error = GroupMutableMetadataError;
319
320 fn try_from(value: &Vec<u8>) -> Result<Self, Self::Error> {
322 let proto_val = GroupMutableMetadataProto::decode(value.as_slice())?;
323 Self::try_from(proto_val)
324 }
325}
326
327impl TryFrom<GroupMutableMetadataProto> for GroupMutableMetadata {
328 type Error = GroupMutableMetadataError;
329
330 fn try_from(value: GroupMutableMetadataProto) -> Result<Self, Self::Error> {
332 let admin_list = value
333 .admin_list
334 .ok_or(GroupMutableMetadataError::MissingMetadataField)?
335 .inbox_ids;
336
337 let super_admin_list = value
338 .super_admin_list
339 .ok_or(GroupMutableMetadataError::MissingMetadataField)?
340 .inbox_ids;
341
342 Ok(Self::new(
343 value.attributes.clone(),
344 admin_list,
345 super_admin_list,
346 ))
347 }
348}
349
350pub fn find_mutable_metadata_extension(extensions: &Extensions<GroupContext>) -> Option<&Vec<u8>> {
355 extensions.iter().find_map(|extension| {
356 if let Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, UnknownExtension(metadata)) =
357 extension
358 {
359 return Some(metadata);
360 }
361 None
362 })
363}
364
365pub const METADATA_FIELD_COMPONENT_MAP: &[(
371 MetadataField,
372 super::app_data::component_id::ComponentId,
373)] = &[
374 (
375 MetadataField::GroupName,
376 super::app_data::component_id::ComponentId::GROUP_NAME,
377 ),
378 (
379 MetadataField::Description,
380 super::app_data::component_id::ComponentId::GROUP_DESCRIPTION,
381 ),
382 (
383 MetadataField::GroupImageUrlSquare,
384 super::app_data::component_id::ComponentId::GROUP_IMAGE_URL,
385 ),
386 (
387 MetadataField::MessageDisappearFromNS,
388 super::app_data::component_id::ComponentId::MESSAGE_DISAPPEAR_FROM_NS,
389 ),
390 (
391 MetadataField::MessageDisappearInNS,
392 super::app_data::component_id::ComponentId::MESSAGE_DISAPPEAR_IN_NS,
393 ),
394 (
395 MetadataField::MinimumSupportedProtocolVersion,
396 super::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
397 ),
398 (
399 MetadataField::CommitLogSigner,
400 super::app_data::component_id::ComponentId::COMMIT_LOG_SIGNER,
401 ),
402 (
403 MetadataField::AppData,
404 super::app_data::component_id::ComponentId::APP_DATA,
405 ),
406];
407
408pub fn merge_dict_into_mutable_metadata(
419 base: &mut GroupMutableMetadata,
420 extensions: &Extensions<GroupContext>,
421) -> Result<(), GroupMutableMetadataError> {
422 use super::app_data::component_id::ComponentId;
423
424 let Some(ext) = extensions.app_data_dictionary() else {
425 return Ok(());
426 };
427 let dict = ext.dictionary();
428
429 for (field, id) in METADATA_FIELD_COMPONENT_MAP {
430 if let Some(bytes) = dict.get(&id.as_u16()) {
431 let legacy_value = decode_metadata_component(*id, bytes)?;
432 base.attributes
433 .insert(field.as_str().to_string(), legacy_value);
434 }
435 }
436
437 insert_absent_bounded_string_defaults(base, &[]);
438
439 for (component_id, list) in [
440 (ComponentId::ADMIN_LIST, &mut base.admin_list),
441 (ComponentId::SUPER_ADMIN_LIST, &mut base.super_admin_list),
442 ] {
443 if let Some(bytes) = dict.get(&component_id.as_u16()) {
444 *list = decode_inbox_id_list(component_id, bytes)?;
445 }
446 }
447 Ok(())
448}
449
450pub fn merge_dict_into_mutable_metadata_lossy(
454 base: &mut GroupMutableMetadata,
455 extensions: &Extensions<GroupContext>,
456) -> Vec<GroupMutableMetadataError> {
457 use super::app_data::component_id::ComponentId;
458
459 let Some(ext) = extensions.app_data_dictionary() else {
460 return vec![];
461 };
462 let dict = ext.dictionary();
463 let mut errors = vec![];
464 let mut unavailable = vec![];
465 for (field, id) in METADATA_FIELD_COMPONENT_MAP {
466 if let Some(bytes) = dict.get(&id.as_u16()) {
467 let value = if *id == ComponentId::COMMIT_LOG_SIGNER && bytes.len() != 32 {
468 Err(GroupMutableMetadataError::MalformedComponent {
469 component_id: Some(*id),
470 reason: format!("expected 32 bytes, got {}", bytes.len()),
471 })
472 } else {
473 decode_metadata_component(*id, bytes)
474 };
475 match value {
476 Ok(value) => {
477 base.attributes.insert(field.as_str().to_string(), value);
478 }
479 Err(error) => {
480 base.attributes.remove(field.as_str());
481 unavailable.push(*field);
482 errors.push(error);
483 }
484 }
485 }
486 }
487 insert_absent_bounded_string_defaults(base, &unavailable);
488 for (id, list) in [
489 (ComponentId::ADMIN_LIST, &mut base.admin_list),
490 (ComponentId::SUPER_ADMIN_LIST, &mut base.super_admin_list),
491 ] {
492 if let Some(bytes) = dict.get(&id.as_u16()) {
493 match decode_inbox_id_list(id, bytes) {
494 Ok(value) => *list = value,
495 Err(error) => {
496 list.clear();
497 errors.push(error);
498 }
499 }
500 }
501 }
502 errors
503}
504
505const DEFAULTED_BOUNDED_STRING_FIELDS: &[(MetadataField, &str)] = &[
519 (MetadataField::GroupName, DEFAULT_GROUP_NAME),
520 (MetadataField::Description, DEFAULT_GROUP_DESCRIPTION),
521 (
522 MetadataField::GroupImageUrlSquare,
523 DEFAULT_GROUP_IMAGE_URL_SQUARE,
524 ),
525 (MetadataField::AppData, ""),
526];
527
528fn insert_absent_bounded_string_defaults(
532 base: &mut GroupMutableMetadata,
533 unavailable: &[MetadataField],
534) {
535 for (field, default) in DEFAULTED_BOUNDED_STRING_FIELDS {
536 if unavailable.contains(field) {
537 continue;
538 }
539 base.attributes
540 .entry(field.as_str().to_string())
541 .or_insert_with(|| (*default).to_string());
542 }
543}
544
545fn decode_metadata_component(
549 id: super::app_data::component_id::ComponentId,
550 bytes: &[u8],
551) -> Result<String, GroupMutableMetadataError> {
552 use super::app_data::component_id::ComponentId;
553
554 match id {
555 ComponentId::MESSAGE_DISAPPEAR_FROM_NS | ComponentId::MESSAGE_DISAPPEAR_IN_NS => {
556 let arr: [u8; 8] =
557 bytes
558 .try_into()
559 .map_err(|_| GroupMutableMetadataError::MalformedComponent {
560 component_id: Some(id),
561 reason: format!("expected 8 bytes (BE i64), got {}", bytes.len()),
562 })?;
563 Ok(i64::from_be_bytes(arr).to_string())
564 }
565 ComponentId::COMMIT_LOG_SIGNER => Ok(hex::encode(bytes)),
566 _ => Ok(std::str::from_utf8(bytes)
567 .map_err(|e| GroupMutableMetadataError::MalformedComponent {
568 component_id: Some(id),
569 reason: format!("non-UTF-8 bytes: {e}"),
570 })?
571 .to_string()),
572 }
573}
574
575fn decode_inbox_id_list(
578 component_id: super::app_data::component_id::ComponentId,
579 bytes: &[u8],
580) -> Result<Vec<String>, GroupMutableMetadataError> {
581 use super::inbox_id::InboxId;
582 use super::tls_set::TlsSet;
583 use tls_codec::Deserialize as _;
584
585 let set = TlsSet::<InboxId>::tls_deserialize_exact(bytes).map_err(|e| {
586 GroupMutableMetadataError::MalformedComponent {
587 component_id: Some(component_id),
588 reason: format!("invalid TlsSet<InboxId>: {e}"),
589 }
590 })?;
591 Ok(set.iter().map(|id| id.to_hex()).collect())
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597 use std::collections::HashMap;
598
599 #[xmtp_common::test]
604 fn message_expiry_from_sent_time() {
605 let settings = MessageDisappearingSettings::new(1_000, 50);
606 assert_eq!(settings.expire_at_ns(999), None);
607 assert_eq!(settings.expire_at_ns(1_000), Some(1_050));
608 assert_eq!(settings.expire_at_ns(5_000), Some(5_050));
609
610 for disabled in [(0, 50), (1_000, 0), (-1, 50), (1_000, -1)] {
611 let disabled = MessageDisappearingSettings::new(disabled.0, disabled.1);
612 assert_eq!(disabled.expire_at_ns(5_000), None, "{disabled:?}");
613 }
614
615 let long = MessageDisappearingSettings::new(1, i64::MAX);
616 assert_eq!(long.expire_at_ns(1), Some(i64::MAX));
617 assert_eq!(long.expire_at_ns(i64::MAX), Some(i64::MAX));
618 let edge = MessageDisappearingSettings::new(1, i64::MAX - 10);
619 assert_eq!(edge.expire_at_ns(10), Some(i64::MAX));
620 assert_eq!(edge.expire_at_ns(11), Some(i64::MAX));
621 assert_eq!(edge.expire_at_ns(9), Some(i64::MAX - 1));
622 }
623
624 #[xmtp_common::test(unwrap_try = true)]
625 fn test_commit_log_signer_utility_method() {
626 let test_secret_bytes = vec![1u8; 32];
628 let test_secret_hex = hex::encode(&test_secret_bytes);
629
630 let mut attributes = HashMap::new();
631 attributes.insert(
632 MetadataField::CommitLogSigner.to_string(),
633 test_secret_hex.clone(),
634 );
635
636 let metadata = GroupMutableMetadata::new(attributes, vec![], vec![]);
637
638 let retrieved_secret = metadata.commit_log_signer().unwrap();
639 assert_eq!(retrieved_secret.as_slice(), &test_secret_bytes);
640
641 let empty_metadata = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
643 assert!(empty_metadata.commit_log_signer().is_none());
644
645 let mut bad_attributes = HashMap::new();
647 bad_attributes.insert(
648 MetadataField::CommitLogSigner.to_string(),
649 "invalid_hex".to_string(),
650 );
651
652 let bad_metadata = GroupMutableMetadata::new(bad_attributes, vec![], vec![]);
653 assert!(bad_metadata.commit_log_signer().is_none());
654 }
655
656 #[xmtp_common::test(unwrap_try = true)]
662 fn test_merge_defaults_absent_bounded_string_components() {
663 use super::super::app_data::component_id::ComponentId;
664 use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension};
665 use openmls::group::GroupContext;
666
667 let mut dict = AppDataDictionary::new();
671 let _ = dict.insert(ComponentId::GROUP_NAME.as_u16(), b"Only Name".to_vec());
672 let extensions: Extensions<GroupContext> =
673 Extensions::from_vec(vec![Extension::AppDataDictionary(
674 AppDataDictionaryExtension::new(dict),
675 )])
676 .unwrap();
677
678 let mut base = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
679 merge_dict_into_mutable_metadata(&mut base, &extensions).unwrap();
680
681 assert_eq!(
683 base.attributes
684 .get(MetadataField::GroupName.as_str())
685 .map(String::as_str),
686 Some("Only Name"),
687 "a present component must not be overwritten by its default",
688 );
689
690 for field in [
692 MetadataField::Description,
693 MetadataField::GroupImageUrlSquare,
694 MetadataField::AppData,
695 ] {
696 assert_eq!(
697 base.attributes.get(field.as_str()).map(String::as_str),
698 Some(""),
699 "absent {} must default to an empty string",
700 field.as_str(),
701 );
702 }
703
704 for field in [
707 MetadataField::MessageDisappearFromNS,
708 MetadataField::MessageDisappearInNS,
709 MetadataField::MinimumSupportedProtocolVersion,
710 MetadataField::CommitLogSigner,
711 ] {
712 assert!(
713 !base.attributes.contains_key(field.as_str()),
714 "{} must not gain a default",
715 field.as_str(),
716 );
717 }
718 }
719
720 #[xmtp_common::test(unwrap_try = true)]
723 fn test_merge_rejects_malformed_component() {
724 use super::super::app_data::component_id::ComponentId;
725 use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension};
726 use openmls::group::GroupContext;
727
728 let mut dict = AppDataDictionary::new();
729 let _ = dict.insert(ComponentId::GROUP_NAME.as_u16(), b"Good Name".to_vec());
730 let _ = dict.insert(ComponentId::ADMIN_LIST.as_u16(), vec![0xff, 0xff, 0xff]);
731 let extensions: Extensions<GroupContext> =
732 Extensions::from_vec(vec![Extension::AppDataDictionary(
733 AppDataDictionaryExtension::new(dict),
734 )])
735 .unwrap();
736
737 let mut base = GroupMutableMetadata::new(HashMap::new(), vec![], vec![]);
738 assert!(matches!(
739 merge_dict_into_mutable_metadata(&mut base, &extensions),
740 Err(GroupMutableMetadataError::MalformedComponent {
741 component_id: Some(ComponentId::ADMIN_LIST),
742 ..
743 })
744 ));
745 }
746
747 #[xmtp_common::test(unwrap_try = true)]
749 fn test_lossy_merge_omits_malformed_fields_without_defaults() {
750 use super::super::app_data::component_id::ComponentId;
751 use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension};
752 use openmls::group::GroupContext;
753
754 let mut dict = AppDataDictionary::new();
755 let _ = dict.insert(ComponentId::GROUP_NAME.as_u16(), vec![0xff]);
756 let _ = dict.insert(
757 ComponentId::GROUP_DESCRIPTION.as_u16(),
758 b"Readable".to_vec(),
759 );
760 let _ = dict.insert(ComponentId::ADMIN_LIST.as_u16(), vec![0xff, 0xff, 0xff]);
761 let _ = dict.insert(ComponentId::COMMIT_LOG_SIGNER.as_u16(), vec![1, 2, 3]);
762 let extensions: Extensions<GroupContext> =
763 Extensions::from_vec(vec![Extension::AppDataDictionary(
764 AppDataDictionaryExtension::new(dict),
765 )])?;
766 let mut base = GroupMutableMetadata::new(
767 HashMap::from([
768 ("group_name".to_string(), "Stale".to_string()),
769 (
770 MetadataField::CommitLogSigner.as_str().to_string(),
771 hex::encode([7; 32]),
772 ),
773 ]),
774 vec!["stale-admin".to_string()],
775 vec![],
776 );
777 let errors = merge_dict_into_mutable_metadata_lossy(&mut base, &extensions);
778 let mut components: Vec<_> = errors
779 .into_iter()
780 .map(|error| match error {
781 GroupMutableMetadataError::MalformedComponent {
782 component_id: Some(id),
783 ..
784 } => id.as_u16(),
785 error => panic!("unexpected metadata error: {error:?}"),
786 })
787 .collect();
788 components.sort();
789 let mut expected = [
790 ComponentId::GROUP_NAME.as_u16(),
791 ComponentId::ADMIN_LIST.as_u16(),
792 ComponentId::COMMIT_LOG_SIGNER.as_u16(),
793 ];
794 expected.sort();
795 assert_eq!(components, expected);
796 assert!(!base.attributes.contains_key("group_name"));
797 assert!(
798 !base
799 .attributes
800 .contains_key(MetadataField::CommitLogSigner.as_str())
801 );
802 assert_eq!(base.attributes["description"], "Readable");
803 assert_eq!(base.attributes["group_image_url_square"], "");
804 assert_eq!(base.attributes["app_data"], "");
805 assert!(base.admin_list.is_empty());
806 }
807}