field_events.rs (14850B)
1 #![cfg(feature = "serde_json")] 2 3 use radroots_events::{ 4 farm::RadrootsFarmRef, 5 farm_crdt::{ 6 RADROOTS_FARM_CRDT_CHANGE_SCHEMA, RadrootsCrdtBackend, RadrootsFarmCrdtChange, 7 RadrootsFarmCrdtDocumentKind, RadrootsFarmSemanticKind, 8 }, 9 farm_file::{RadrootsFarmFileDimensions, RadrootsFarmFileMetadata, RadrootsFarmFileSource}, 10 farm_workspace::{ 11 RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION, RADROOTS_FARM_WORKSPACE_SCHEMA, 12 RadrootsFarmWorkspaceManifest, RadrootsFarmWorkspaceMediaServer, RadrootsFarmWorkspaceRef, 13 RadrootsFarmWorkspaceRelay, RadrootsFarmWorkspaceRelayMode, 14 }, 15 group::{ 16 KIND_GROUP_CREATE_INVITE, KIND_GROUP_METADATA, RadrootsGroupAdmins, 17 RadrootsGroupCreateInvite, RadrootsGroupEditableMetadata, RadrootsGroupMetadata, 18 RadrootsGroupPutUser, RadrootsGroupUserRef, 19 }, 20 http_auth::RadrootsHttpAuth, 21 kinds::{KIND_FARM_FILE_METADATA, KIND_POST}, 22 relay_auth::RadrootsRelayAuth, 23 }; 24 use radroots_events_codec::{ 25 error::EventParseError, 26 farm_crdt::{ 27 decode::farm_crdt_change_from_event_with_author, 28 encode::{to_wire_parts as crdt_to_wire_parts, to_wire_parts_with_author}, 29 }, 30 farm_file::{ 31 decode::farm_file_metadata_from_event, encode::to_wire_parts as file_to_wire_parts, 32 }, 33 farm_workspace::{ 34 decode::farm_workspace_from_event, encode::to_wire_parts as workspace_to_wire_parts, 35 }, 36 group::{ 37 decode::{ 38 group_admins_from_event, group_create_invite_from_event, group_metadata_from_event, 39 group_put_user_from_event, 40 }, 41 encode::{ 42 group_admins_to_wire_parts, group_create_invite_to_wire_parts, 43 group_metadata_to_wire_parts, group_put_user_to_wire_parts, 44 }, 45 }, 46 http_auth::{decode::http_auth_from_event, encode::to_wire_parts as http_auth_to_wire_parts}, 47 relay_auth::{ 48 decode::relay_auth_from_event, encode::to_wire_parts as relay_auth_to_wire_parts, 49 }, 50 }; 51 52 const WORKSPACE_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; 53 const FILE_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAQ"; 54 const DOCUMENT_ID: &str = "AAAAAAAAAAAAAAAAAAAAAg"; 55 const GROUP_ID: &str = "field-group"; 56 const AUTHOR: &str = "author_pubkey"; 57 const SHA256: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; 58 59 #[test] 60 fn field_codec_matrix_roundtrips_all_new_event_families() { 61 let workspace = sample_workspace(); 62 let workspace_parts = workspace_to_wire_parts(&workspace).expect("workspace parts"); 63 assert_eq!( 64 farm_workspace_from_event( 65 workspace_parts.kind, 66 &workspace_parts.tags, 67 &workspace_parts.content, 68 ) 69 .expect("workspace decode") 70 .d_tag, 71 WORKSPACE_D_TAG 72 ); 73 74 let crdt = sample_crdt_change(); 75 let crdt_parts = to_wire_parts_with_author(&crdt, AUTHOR).expect("crdt parts"); 76 assert_eq!( 77 farm_crdt_change_from_event_with_author( 78 crdt_parts.kind, 79 &crdt_parts.tags, 80 &crdt_parts.content, 81 AUTHOR, 82 ) 83 .expect("crdt decode") 84 .document_id, 85 DOCUMENT_ID 86 ); 87 88 let file = sample_file_metadata(); 89 let file_parts = file_to_wire_parts(&file).expect("file parts"); 90 assert_eq!( 91 farm_file_metadata_from_event(file_parts.kind, &file_parts.tags, &file_parts.content) 92 .expect("file decode"), 93 file 94 ); 95 96 let relay_auth = RadrootsRelayAuth { 97 relay: "wss://relay.example.invalid/farm/field-group".to_string(), 98 challenge: "relay-provided-challenge".to_string(), 99 }; 100 let relay_parts = relay_auth_to_wire_parts(&relay_auth).expect("relay auth parts"); 101 assert_eq!( 102 relay_auth_from_event(relay_parts.kind, &relay_parts.tags, &relay_parts.content) 103 .expect("relay auth decode"), 104 relay_auth 105 ); 106 107 let http_auth = RadrootsHttpAuth { 108 url: "https://media.example.invalid/upload".to_string(), 109 method: "POST".to_string(), 110 payload_sha256: Some(SHA256.to_string()), 111 }; 112 let http_parts = http_auth_to_wire_parts(&http_auth).expect("http auth parts"); 113 assert_eq!( 114 http_auth_from_event(http_parts.kind, &http_parts.tags, &http_parts.content) 115 .expect("http auth decode"), 116 http_auth 117 ); 118 119 let metadata = RadrootsGroupMetadata { 120 d_tag: GROUP_ID.to_string(), 121 metadata: sample_group_metadata(), 122 }; 123 let metadata_parts = group_metadata_to_wire_parts(&metadata).expect("metadata parts"); 124 assert_eq!( 125 group_metadata_from_event( 126 metadata_parts.kind, 127 &metadata_parts.tags, 128 &metadata_parts.content, 129 ) 130 .expect("metadata decode"), 131 metadata 132 ); 133 134 let admins = RadrootsGroupAdmins { 135 d_tag: GROUP_ID.to_string(), 136 description: Some("field group admins".to_string()), 137 admins: vec![RadrootsGroupUserRef { 138 pubkey: "admin_pubkey".to_string(), 139 roles: vec!["admin".to_string()], 140 }], 141 }; 142 let admins_parts = group_admins_to_wire_parts(&admins).expect("admins parts"); 143 assert_eq!( 144 group_admins_from_event(admins_parts.kind, &admins_parts.tags, &admins_parts.content) 145 .expect("admins decode"), 146 admins 147 ); 148 149 let put = RadrootsGroupPutUser { 150 group_id: GROUP_ID.to_string(), 151 message: Some("add field member".to_string()), 152 pubkey: "member_pubkey".to_string(), 153 roles: vec!["member".to_string()], 154 }; 155 let put_parts = group_put_user_to_wire_parts(&put).expect("put parts"); 156 assert_eq!( 157 group_put_user_from_event(put_parts.kind, &put_parts.tags, &put_parts.content) 158 .expect("put decode"), 159 put 160 ); 161 162 let invite = RadrootsGroupCreateInvite { 163 group_id: GROUP_ID.to_string(), 164 message: Some("join the field group".to_string()), 165 code: "invite-code".to_string(), 166 }; 167 let invite_parts = group_create_invite_to_wire_parts(&invite).expect("invite parts"); 168 assert_eq!( 169 group_create_invite_from_event( 170 invite_parts.kind, 171 &invite_parts.tags, 172 &invite_parts.content 173 ) 174 .expect("invite decode"), 175 invite 176 ); 177 } 178 179 #[test] 180 fn field_codec_matrix_rejects_missing_required_tags_and_mismatches() { 181 let workspace_parts = workspace_to_wire_parts(&sample_workspace()).expect("workspace parts"); 182 let workspace_without_h = without_tag(&workspace_parts.tags, "h"); 183 assert!(matches!( 184 farm_workspace_from_event( 185 workspace_parts.kind, 186 &workspace_without_h, 187 &workspace_parts.content, 188 ), 189 Err(EventParseError::MissingTag("h")) 190 )); 191 192 let file_parts = file_to_wire_parts(&sample_file_metadata()).expect("file parts"); 193 let file_without_x = without_tag(&file_parts.tags, "x"); 194 assert!(matches!( 195 farm_file_metadata_from_event(file_parts.kind, &file_without_x, &file_parts.content), 196 Err(EventParseError::MissingTag("x")) 197 )); 198 199 let mut duplicate_d = file_parts.tags.clone(); 200 duplicate_d.push(vec!["d".to_string(), WORKSPACE_D_TAG.to_string()]); 201 assert!(matches!( 202 farm_file_metadata_from_event(file_parts.kind, &duplicate_d, &file_parts.content), 203 Err(EventParseError::InvalidTag("d")) 204 )); 205 206 let put_parts = group_put_user_to_wire_parts(&RadrootsGroupPutUser { 207 group_id: GROUP_ID.to_string(), 208 message: None, 209 pubkey: "member_pubkey".to_string(), 210 roles: vec!["member".to_string()], 211 }) 212 .expect("put parts"); 213 assert!(matches!( 214 group_put_user_from_event(put_parts.kind, &without_tag(&put_parts.tags, "h"), ""), 215 Err(EventParseError::MissingTag("h")) 216 )); 217 218 let valued_marker_tags = vec![ 219 vec!["d".to_string(), GROUP_ID.to_string()], 220 vec!["private".to_string(), "true".to_string()], 221 ]; 222 assert!(matches!( 223 group_metadata_from_event(KIND_GROUP_METADATA, &valued_marker_tags, ""), 224 Err(EventParseError::InvalidTag("private")) 225 )); 226 227 let first_pass_invite_tags = vec![ 228 vec!["h".to_string(), GROUP_ID.to_string()], 229 vec!["p".to_string(), "member_pubkey".to_string()], 230 vec!["role".to_string(), "member".to_string()], 231 vec!["claim".to_string(), "claim-token".to_string()], 232 ]; 233 assert!(matches!( 234 group_create_invite_from_event(KIND_GROUP_CREATE_INVITE, &first_pass_invite_tags, ""), 235 Err(EventParseError::MissingTag("code")) 236 )); 237 } 238 239 #[test] 240 fn field_codec_matrix_rejects_bad_hash_base64_kind_and_content() { 241 let mut file_parts = file_to_wire_parts(&sample_file_metadata()).expect("file parts"); 242 replace_tag_value(&mut file_parts.tags, "x", "ABC"); 243 assert!(matches!( 244 farm_file_metadata_from_event(file_parts.kind, &file_parts.tags, &file_parts.content), 245 Err(EventParseError::InvalidTag("x")) 246 )); 247 248 let crdt_parts = crdt_to_wire_parts(&sample_crdt_change()).expect("crdt parts"); 249 let mut bad_crdt = sample_crdt_change(); 250 bad_crdt.encoded_change = "abc/def".to_string(); 251 let bad_crdt_content = serde_json::to_string(&bad_crdt).expect("bad crdt content"); 252 assert!(matches!( 253 farm_crdt_change_from_event_with_author( 254 crdt_parts.kind, 255 &crdt_parts.tags, 256 &bad_crdt_content, 257 AUTHOR, 258 ), 259 Err(EventParseError::InvalidJson("encoded_change")) 260 )); 261 262 assert!(matches!( 263 farm_workspace_from_event(KIND_POST, &[], ""), 264 Err(EventParseError::InvalidKind { 265 expected: "30078", 266 got: KIND_POST 267 }) 268 )); 269 270 let relay_parts = relay_auth_to_wire_parts(&RadrootsRelayAuth { 271 relay: "wss://relay.example.invalid/farm/field-group".to_string(), 272 challenge: "relay-provided-challenge".to_string(), 273 }) 274 .expect("relay auth parts"); 275 assert!(matches!( 276 relay_auth_from_event(relay_parts.kind, &relay_parts.tags, "not empty"), 277 Err(EventParseError::InvalidJson("content")) 278 )); 279 280 let mut http_parts = http_auth_to_wire_parts(&RadrootsHttpAuth { 281 url: "https://media.example.invalid/upload".to_string(), 282 method: "POST".to_string(), 283 payload_sha256: Some(SHA256.to_string()), 284 }) 285 .expect("http auth parts"); 286 replace_tag_value(&mut http_parts.tags, "payload", "ABC"); 287 assert!(matches!( 288 http_auth_from_event(http_parts.kind, &http_parts.tags, &http_parts.content), 289 Err(EventParseError::InvalidTag("payload")) 290 )); 291 } 292 293 fn sample_workspace() -> RadrootsFarmWorkspaceManifest { 294 RadrootsFarmWorkspaceManifest { 295 d_tag: WORKSPACE_D_TAG.to_string(), 296 schema: RADROOTS_FARM_WORKSPACE_SCHEMA.to_string(), 297 farm_group_id: GROUP_ID.to_string(), 298 name: "Small Regen Farm".to_string(), 299 owner_pubkey: "workspace_owner_pubkey".to_string(), 300 farm: Some(RadrootsFarmRef { 301 pubkey: "farm_pubkey".to_string(), 302 d_tag: FILE_D_TAG.to_string(), 303 }), 304 relays: vec![RadrootsFarmWorkspaceRelay { 305 url: "wss://relay.example.invalid/farm/field-group".to_string(), 306 mode: RadrootsFarmWorkspaceRelayMode::ReadWrite, 307 }], 308 media_servers: vec![RadrootsFarmWorkspaceMediaServer { 309 url: "https://media.example.invalid/farm/field-group".to_string(), 310 service: "RadrootsPrivateMedia".to_string(), 311 }], 312 supported_kinds: vec![78, 30078, KIND_FARM_FILE_METADATA], 313 protocol_version: RADROOTS_FARM_WORKSPACE_PROTOCOL_VERSION.to_string(), 314 created_at_ms: 1_780_000_000_000, 315 updated_at_ms: None, 316 } 317 } 318 319 fn sample_crdt_change() -> RadrootsFarmCrdtChange { 320 RadrootsFarmCrdtChange { 321 schema: RADROOTS_FARM_CRDT_CHANGE_SCHEMA.to_string(), 322 workspace: RadrootsFarmWorkspaceRef { 323 pubkey: "workspace_pubkey".to_string(), 324 d_tag: WORKSPACE_D_TAG.to_string(), 325 }, 326 farm_group_id: GROUP_ID.to_string(), 327 document_id: DOCUMENT_ID.to_string(), 328 document_kind: RadrootsFarmCrdtDocumentKind::FarmTask, 329 crdt_backend: RadrootsCrdtBackend::Automerge, 330 crdt_backend_version: Some("0.x".to_string()), 331 actor_id: "actor_abc".to_string(), 332 change_hash: "crdt_hash_abc".to_string(), 333 dependencies: Vec::new(), 334 encoded_change: "abc-DEF_012".to_string(), 335 semantic_kind: RadrootsFarmSemanticKind::FarmTaskCreate, 336 business_time_ms: 1_780_000_000_000, 337 author_member_id: Some("member_abc".to_string()), 338 app_version: Some("0.1.0".to_string()), 339 } 340 } 341 342 fn sample_file_metadata() -> RadrootsFarmFileMetadata { 343 RadrootsFarmFileMetadata { 344 d_tag: FILE_D_TAG.to_string(), 345 workspace: RadrootsFarmWorkspaceRef { 346 pubkey: "workspace_pubkey".to_string(), 347 d_tag: WORKSPACE_D_TAG.to_string(), 348 }, 349 farm_group_id: GROUP_ID.to_string(), 350 owner_document_id: DOCUMENT_ID.to_string(), 351 owner_document_kind: RadrootsFarmCrdtDocumentKind::FarmTask, 352 caption: Some("Tomatoes harvested from Patch Y.".to_string()), 353 url: "https://media.example.invalid/blob/sha256".to_string(), 354 mime_type: "image/jpeg".to_string(), 355 sha256: SHA256.to_string(), 356 original_sha256: None, 357 size_bytes: Some(123_456), 358 dimensions: Some(RadrootsFarmFileDimensions { w: 1600, h: 1200 }), 359 blurhash: None, 360 thumb: Some(RadrootsFarmFileSource { 361 url: "https://media.example.invalid/thumb/sha256".to_string(), 362 mime_type: Some("image/jpeg".to_string()), 363 dimensions: Some(RadrootsFarmFileDimensions { w: 320, h: 240 }), 364 }), 365 image: None, 366 alt: Some("Harvested tomatoes in a crate".to_string()), 367 fallbacks: Vec::new(), 368 } 369 } 370 371 fn sample_group_metadata() -> RadrootsGroupEditableMetadata { 372 RadrootsGroupEditableMetadata { 373 name: Some("Small Regen Farm".to_string()), 374 about: Some("Field app group".to_string()), 375 picture: Some("https://media.example.invalid/group.png".to_string()), 376 is_private: false, 377 is_restricted: true, 378 is_closed: false, 379 is_hidden: false, 380 supported_kinds: Some(vec![78, 30078, KIND_FARM_FILE_METADATA]), 381 } 382 } 383 384 fn without_tag(tags: &[Vec<String>], key: &str) -> Vec<Vec<String>> { 385 tags.iter() 386 .filter(|tag| tag.first().map(|value| value.as_str()) != Some(key)) 387 .cloned() 388 .collect() 389 } 390 391 fn replace_tag_value(tags: &mut [Vec<String>], key: &str, value: &str) { 392 for tag in tags { 393 if tag.first().map(|tag_key| tag_key.as_str()) == Some(key) && tag.len() > 1 { 394 tag[1] = value.to_string(); 395 } 396 } 397 }