lib

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

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 }