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