lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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 }