lib

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

verified_comment_conformance.rs (22935B)


      1 #![cfg(feature = "json")]
      2 
      3 use std::{
      4     borrow::Cow,
      5     collections::{BTreeMap, BTreeSet},
      6     fs,
      7     path::Path,
      8 };
      9 
     10 use radroots_event::{
     11     envelope::EventEnvelope,
     12     envelope::EventEnvelopeLimits,
     13     envelope::EventEnvelopeParts,
     14     post::comment::{
     15         AuthoredNip22Comment, Nip22AddressRootReference, Nip22CommentError,
     16         Nip22CommentParentReference, Nip22EventRootReference,
     17     },
     18 };
     19 use radroots_event_codec::{
     20     admission::comment::verify_and_admit_nip22_comment_event,
     21     decode::comment::{
     22         RadrootsInboundNip22CommentPosition, RadrootsInboundNip22CommentProjection,
     23         RadrootsInboundNip22CommentRoot, RadrootsInboundNip22Participant,
     24         project_verified_nip22_comment_event,
     25     },
     26     encode::comment::authored_nip22_comment_to_wire_parts,
     27     verify::verify_nip01_event,
     28 };
     29 use serde::{Deserialize, Serialize};
     30 use serde_json::{Value, json};
     31 
     32 const PACKAGED_VECTORS: &str = include_str!("fixtures/comment_verified_profile.v1.json");
     33 const WORKSPACE_VECTOR_PATH: &str =
     34     "../../contracts/conformance/vectors/comment/verified_profile.v1.json";
     35 const WORKSPACE_CONTRACT_MARKER_PATH: &str = "../../contracts/manifest.toml";
     36 
     37 #[derive(Debug, Deserialize)]
     38 struct Suite {
     39     suite: String,
     40     contract_version: String,
     41     vectors: Vec<Vector>,
     42 }
     43 
     44 #[derive(Debug, Deserialize)]
     45 struct Vector {
     46     id: String,
     47     kind: String,
     48     input: Value,
     49     expected: Value,
     50 }
     51 
     52 #[derive(Clone, Debug, Deserialize, Serialize)]
     53 #[serde(deny_unknown_fields)]
     54 struct RawEvent {
     55     id: String,
     56     pubkey: String,
     57     created_at: u64,
     58     kind: u32,
     59     tags: Vec<Vec<String>>,
     60     content: String,
     61     sig: String,
     62 }
     63 
     64 #[test]
     65 fn comment_operation_vectors_execute_the_declared_public_functions() {
     66     let vectors = conformance_vectors();
     67     assert!(
     68         !vectors.contains("mutation")
     69             && !vectors.contains("\"base\"")
     70             && !vectors.contains("secret_key"),
     71         "frozen Comment corpus must not contain runtime generation descriptors"
     72     );
     73     let suite: Suite = serde_json::from_str(&vectors).expect("Comment vectors must parse");
     74     assert_eq!(suite.suite, "nip22_comment_profile");
     75     assert_eq!(suite.contract_version, "1.0.0");
     76     assert_vector_inventory(&suite.vectors);
     77 
     78     for vector in &suite.vectors {
     79         execute(vector);
     80     }
     81 }
     82 
     83 fn conformance_vectors() -> Cow<'static, str> {
     84     let workspace_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(WORKSPACE_VECTOR_PATH);
     85     match fs::read_to_string(&workspace_path) {
     86         Ok(canonical) => {
     87             assert_eq!(
     88                 canonical,
     89                 PACKAGED_VECTORS,
     90                 "packaged Comment vectors must match {}",
     91                 workspace_path.display()
     92             );
     93             Cow::Owned(canonical)
     94         }
     95         Err(error)
     96             if error.kind() == std::io::ErrorKind::NotFound
     97                 && !Path::new(env!("CARGO_MANIFEST_DIR"))
     98                     .join(WORKSPACE_CONTRACT_MARKER_PATH)
     99                     .is_file() =>
    100         {
    101             Cow::Borrowed(PACKAGED_VECTORS)
    102         }
    103         Err(error) => panic!("failed to read {}: {error}", workspace_path.display()),
    104     }
    105 }
    106 
    107 fn assert_vector_inventory(vectors: &[Vector]) {
    108     let mut ids = BTreeMap::new();
    109     let mut kinds = BTreeMap::new();
    110     let mut authored_errors = BTreeSet::new();
    111     let mut projection_errors = BTreeSet::new();
    112     let mut diagnostic_codes = BTreeSet::new();
    113     for vector in vectors {
    114         assert!(
    115             ids.insert(vector.id.as_str(), vector.kind.as_str())
    116                 .is_none(),
    117             "duplicate Comment vector id {}",
    118             vector.id
    119         );
    120         *kinds.entry(vector.kind.as_str()).or_insert(0usize) += 1;
    121         if vector.kind == "social.comment.build_authored_draft.invalid" {
    122             authored_errors.insert(expected_str(vector, "error"));
    123         }
    124         if vector.kind == "social.comment.project_verified_event.invalid" {
    125             projection_errors.insert(expected_str(vector, "error"));
    126         }
    127         if vector.kind == "social.comment.project_verified_event.valid" {
    128             for diagnostic in vector.expected["diagnostics"]
    129                 .as_array()
    130                 .unwrap_or_else(|| panic!("{} expected.diagnostics must be an array", vector.id))
    131             {
    132                 diagnostic_codes.insert(
    133                     diagnostic["code"].as_str().unwrap_or_else(|| {
    134                         panic!("{} diagnostic.code must be a string", vector.id)
    135                     }),
    136                 );
    137             }
    138         }
    139 
    140         if vector.kind.contains("project_verified_event")
    141             || vector.kind.contains("verify_and_admit_event")
    142         {
    143             let input = vector
    144                 .input
    145                 .as_object()
    146                 .unwrap_or_else(|| panic!("{} input must be an object", vector.id));
    147             assert_eq!(
    148                 input.keys().map(String::as_str).collect::<Vec<_>>(),
    149                 ["event_json"],
    150                 "{} must contain only fixed event_json",
    151                 vector.id
    152             );
    153         } else {
    154             let input = vector
    155                 .input
    156                 .as_object()
    157                 .unwrap_or_else(|| panic!("{} input must be an object", vector.id));
    158             assert_eq!(
    159                 input.keys().map(String::as_str).collect::<Vec<_>>(),
    160                 ["content", "position", "root"],
    161                 "{} authored input fields drifted",
    162                 vector.id
    163             );
    164         }
    165     }
    166     assert_eq!(vectors.len(), 114);
    167     assert_eq!(
    168         kinds,
    169         BTreeMap::from([
    170             ("social.comment.build_authored_draft.invalid", 17),
    171             ("social.comment.build_authored_draft.valid", 14),
    172             ("social.comment.project_verified_event.invalid", 45),
    173             ("social.comment.project_verified_event.valid", 30),
    174             ("social.comment.verify_and_admit_event.invalid", 4),
    175             ("social.comment.verify_and_admit_event.valid", 4),
    176         ])
    177     );
    178     assert_eq!(
    179         authored_errors,
    180         BTreeSet::from([
    181             "comment_content_missing",
    182             "comment_content_too_large",
    183             "comment_event_wire_too_large",
    184             "comment_parent_author_invalid",
    185             "comment_parent_event_id_invalid",
    186             "comment_parent_reference_mismatch",
    187             "comment_relay_invalid",
    188             "comment_revision_event_id_invalid",
    189             "comment_root_author_invalid",
    190             "comment_root_coordinate_invalid",
    191             "comment_root_event_id_invalid",
    192             "comment_root_kind_unsupported",
    193             "comment_tag_element_too_large",
    194         ])
    195     );
    196     assert_eq!(
    197         projection_errors,
    198         BTreeSet::from([
    199             "comment_content_missing",
    200             "comment_content_too_large",
    201             "comment_event_wire_too_large",
    202             "comment_parent_author_ambiguous",
    203             "comment_parent_author_mismatch",
    204             "comment_parent_author_missing",
    205             "comment_parent_cardinality",
    206             "comment_parent_coordinate_invalid",
    207             "comment_parent_event_id_invalid",
    208             "comment_parent_form_unsupported",
    209             "comment_parent_kind_cardinality",
    210             "comment_parent_kind_invalid",
    211             "comment_parent_reference_mismatch",
    212             "comment_parent_reference_shape",
    213             "comment_revision_event_id_invalid",
    214             "comment_revision_missing",
    215             "comment_revision_shape",
    216             "comment_root_author_cardinality",
    217             "comment_root_author_invalid",
    218             "comment_root_author_mismatch",
    219             "comment_root_cardinality",
    220             "comment_root_coordinate_invalid",
    221             "comment_root_event_id_invalid",
    222             "comment_root_form_unsupported",
    223             "comment_root_kind_cardinality",
    224             "comment_root_kind_mismatch",
    225             "comment_root_kind_unsupported",
    226             "comment_root_reference_shape",
    227             "comment_tag_bytes_exceeded",
    228             "comment_tag_count_exceeded",
    229             "comment_tag_element_count_exceeded",
    230             "comment_tag_element_too_large",
    231             "unsupported_kind",
    232         ])
    233     );
    234     assert_eq!(
    235         diagnostic_codes,
    236         BTreeSet::from([
    237             "comment_parent_author_duplicate_ignored",
    238             "comment_parent_author_hint_ignored",
    239             "comment_parent_author_invalid_ignored",
    240             "comment_parent_author_relay_ignored",
    241             "comment_parent_author_shape_ignored",
    242             "comment_parent_relay_ignored",
    243             "comment_revision_relay_ignored",
    244             "comment_root_author_hint_ignored",
    245             "comment_root_author_relay_ignored",
    246             "comment_root_relay_ignored",
    247         ])
    248     );
    249 }
    250 
    251 fn execute(vector: &Vector) {
    252     match vector.kind.as_str() {
    253         "social.comment.build_authored_draft.valid" => {
    254             let comment = authored_comment(vector)
    255                 .unwrap_or_else(|error| panic!("{} failed: {error}", vector.id));
    256             let first = authored_nip22_comment_to_wire_parts(&comment);
    257             let second = authored_nip22_comment_to_wire_parts(&comment);
    258             assert_eq!(first, second, "{} repeat encoding drifted", vector.id);
    259             assert_eq!(
    260                 serde_json::to_value(first).expect("authored Comment result must serialize"),
    261                 vector.expected,
    262                 "{}",
    263                 vector.id
    264             );
    265         }
    266         "social.comment.build_authored_draft.invalid" => {
    267             let error = authored_comment(vector).expect_err("invalid authored Comment must fail");
    268             assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id);
    269         }
    270         "social.comment.project_verified_event.valid" => {
    271             let verified = verify_nip01_event(fixture_envelope(vector))
    272                 .unwrap_or_else(|error| panic!("{} verification failed: {error}", vector.id));
    273             let projection = project_verified_nip22_comment_event(&verified)
    274                 .unwrap_or_else(|error| panic!("{} projection failed: {error}", vector.id));
    275             assert_eq!(
    276                 projection_snapshot(&projection),
    277                 vector.expected,
    278                 "{}",
    279                 vector.id
    280             );
    281         }
    282         "social.comment.project_verified_event.invalid" => {
    283             let verified = verify_nip01_event(fixture_envelope(vector))
    284                 .unwrap_or_else(|error| panic!("{} verification failed: {error}", vector.id));
    285             let error = project_verified_nip22_comment_event(&verified)
    286                 .expect_err("invalid verified Comment projection must fail");
    287             assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id);
    288         }
    289         "social.comment.verify_and_admit_event.valid" => {
    290             let envelope = fixture_envelope(vector);
    291             let expected_event = envelope.clone();
    292             let admitted = verify_and_admit_nip22_comment_event(envelope)
    293                 .unwrap_or_else(|error| panic!("{} admission failed: {error}", vector.id));
    294             assert_eq!(admitted.event(), &expected_event, "{}", vector.id);
    295             assert_eq!(admitted.contract().id, "radroots.social.comment.v1");
    296             assert_eq!(
    297                 projection_snapshot(admitted.projection()),
    298                 vector.expected,
    299                 "{}",
    300                 vector.id
    301             );
    302             let (verified, projection) = admitted.into_parts();
    303             assert_eq!(verified.event(), &expected_event, "{}", vector.id);
    304             assert_eq!(projection.contract_id(), "radroots.social.comment.v1");
    305         }
    306         "social.comment.verify_and_admit_event.invalid" => {
    307             let error = verify_and_admit_nip22_comment_event(fixture_envelope(vector))
    308                 .expect_err("invalid signed Comment must fail");
    309             assert_eq!(error.code(), expected_str(vector, "error"), "{}", vector.id);
    310         }
    311         other => panic!("{} has unknown vector kind {other}", vector.id),
    312     }
    313 }
    314 
    315 fn authored_comment(vector: &Vector) -> Result<AuthoredNip22Comment, Nip22CommentError> {
    316     let content = input_str(vector, "content");
    317     let root = input_object(vector, "root");
    318     let position = input_object(vector, "position");
    319     let position_type = object_str(position, "type", &vector.id);
    320 
    321     match object_str(root, "type", &vector.id) {
    322         "event" => {
    323             let root_reference = Nip22EventRootReference::parse(
    324                 object_str(root, "event_id", &vector.id),
    325                 object_str(root, "author", &vector.id),
    326                 object_u32(root, "kind", &vector.id),
    327                 object_optional_str(root, "relay", &vector.id),
    328             )?;
    329             match position_type {
    330                 "top_event" => AuthoredNip22Comment::top_level_event(content, root_reference),
    331                 "nested" => AuthoredNip22Comment::nested(
    332                     content,
    333                     root_reference,
    334                     nested_parent(position, &vector.id)?,
    335                 ),
    336                 other => panic!("{} event root has incompatible position {other}", vector.id),
    337             }
    338         }
    339         "address" => {
    340             let root_reference = Nip22AddressRootReference::parse(
    341                 object_str(root, "coordinate", &vector.id),
    342                 object_optional_str(root, "relay", &vector.id),
    343             )?;
    344             assert_eq!(
    345                 root_reference.author().to_hex(),
    346                 object_str(root, "author", &vector.id),
    347                 "{} declared address author drifted",
    348                 vector.id
    349             );
    350             assert_eq!(
    351                 root_reference.kind().as_u32(),
    352                 object_u32(root, "kind", &vector.id),
    353                 "{} declared address kind drifted",
    354                 vector.id
    355             );
    356             match position_type {
    357                 "top_address" => AuthoredNip22Comment::parse_top_level_address(
    358                     content,
    359                     root_reference,
    360                     object_str(position, "current_revision", &vector.id),
    361                 ),
    362                 "nested" => AuthoredNip22Comment::nested(
    363                     content,
    364                     root_reference,
    365                     nested_parent(position, &vector.id)?,
    366                 ),
    367                 other => panic!(
    368                     "{} address root has incompatible position {other}",
    369                     vector.id
    370                 ),
    371             }
    372         }
    373         other => panic!("{} has unknown root type {other}", vector.id),
    374     }
    375 }
    376 
    377 fn nested_parent(
    378     position: &serde_json::Map<String, Value>,
    379     vector_id: &str,
    380 ) -> Result<Nip22CommentParentReference, Nip22CommentError> {
    381     let parent = position["parent"]
    382         .as_object()
    383         .unwrap_or_else(|| panic!("{vector_id} position.parent must be an object"));
    384     Nip22CommentParentReference::parse(
    385         object_str(parent, "event_id", vector_id),
    386         object_str(parent, "author", vector_id),
    387         object_optional_str(parent, "relay", vector_id),
    388     )
    389 }
    390 
    391 fn fixture_envelope(vector: &Vector) -> EventEnvelope {
    392     let event_json = input_str(vector, "event_json");
    393     let raw: RawEvent = serde_json::from_str(event_json)
    394         .unwrap_or_else(|error| panic!("{} event_json failed to parse: {error}", vector.id));
    395     assert_eq!(
    396         serde_json::to_string(&raw).expect("raw event must serialize"),
    397         event_json,
    398         "{} event_json must be compact and canonical",
    399         vector.id
    400     );
    401 
    402     let mut limits = EventEnvelopeLimits::default();
    403     limits.max_content_bytes = limits.max_content_bytes.max(raw.content.len());
    404     limits.max_tag_count = limits.max_tag_count.max(raw.tags.len());
    405     limits.max_total_tag_elements = limits
    406         .max_total_tag_elements
    407         .max(raw.tags.iter().map(Vec::len).sum());
    408     limits.max_tag_element_bytes = limits.max_tag_element_bytes.max(
    409         raw.tags
    410             .iter()
    411             .flat_map(|tag| tag.iter())
    412             .map(String::len)
    413             .max()
    414             .unwrap_or_default(),
    415     );
    416     limits.max_total_tag_bytes = limits.max_total_tag_bytes.max(
    417         raw.tags
    418             .iter()
    419             .flat_map(|tag| tag.iter())
    420             .map(String::len)
    421             .sum(),
    422     );
    423     EventEnvelope::new_with_limits(
    424         EventEnvelopeParts {
    425             id: raw.id,
    426             author: raw.pubkey,
    427             created_at: raw.created_at,
    428             kind: raw.kind,
    429             tags: raw.tags,
    430             content: raw.content,
    431             sig: raw.sig,
    432         },
    433         limits,
    434     )
    435     .unwrap_or_else(|error| panic!("{} envelope failed: {error}", vector.id))
    436 }
    437 
    438 fn projection_snapshot(projection: &RadrootsInboundNip22CommentProjection) -> Value {
    439     let root = match projection.root() {
    440         RadrootsInboundNip22CommentRoot::Event(root) => json!({
    441             "type": "event",
    442             "tag_index": root.tag_index(),
    443             "event_id": root.event_id().to_hex(),
    444             "kind_tag_index": root.kind_tag_index(),
    445             "kind_raw_tag": root.kind_raw_tag(),
    446             "kind": root.kind().as_u32(),
    447             "relay": root.relay().map(|relay| relay.as_str()),
    448             "author_hint": root.author_hint().map(|author| author.to_hex()),
    449             "author": participant_snapshot(root.author()),
    450             "raw_tag": root.raw_tag(),
    451         }),
    452         RadrootsInboundNip22CommentRoot::Address(root) => json!({
    453             "type": "address",
    454             "tag_index": root.tag_index(),
    455             "coordinate": root.coordinate().as_str(),
    456             "kind_tag_index": root.kind_tag_index(),
    457             "kind_raw_tag": root.kind_raw_tag(),
    458             "kind": root.kind().as_u32(),
    459             "relay": root.relay().map(|relay| relay.as_str()),
    460             "author": participant_snapshot(root.author()),
    461             "raw_tag": root.raw_tag(),
    462         }),
    463     };
    464     let position = match projection.position() {
    465         RadrootsInboundNip22CommentPosition::TopLevelEvent { reference } => json!({
    466             "type": "top_event",
    467             "reference": {
    468                 "tag_index": reference.tag_index(),
    469                 "event_id": reference.event_id().to_hex(),
    470                 "kind_tag_index": reference.kind_tag_index(),
    471                 "kind_raw_tag": reference.kind_raw_tag(),
    472                 "kind": reference.kind(),
    473                 "relay": reference.relay().map(|relay| relay.as_str()),
    474                 "author_hint": reference.author_hint().map(|author| author.to_hex()),
    475                 "author": participant_snapshot(reference.author()),
    476                 "raw_tag": reference.raw_tag(),
    477             },
    478         }),
    479         RadrootsInboundNip22CommentPosition::TopLevelAddress {
    480             reference,
    481             current_revision,
    482         } => json!({
    483             "type": "top_address",
    484             "reference": {
    485                 "tag_index": reference.tag_index(),
    486                 "coordinate": reference.coordinate().as_str(),
    487                 "kind_tag_index": reference.kind_tag_index(),
    488                 "kind_raw_tag": reference.kind_raw_tag(),
    489                 "kind": reference.kind(),
    490                 "relay": reference.relay().map(|relay| relay.as_str()),
    491                 "author": participant_snapshot(reference.author()),
    492                 "raw_tag": reference.raw_tag(),
    493             },
    494             "current_revision": {
    495                 "tag_index": current_revision.tag_index(),
    496                 "event_id": current_revision.event_id().to_hex(),
    497                 "relay": current_revision.relay().map(|relay| relay.as_str()),
    498                 "raw_tag": current_revision.raw_tag(),
    499             },
    500         }),
    501         RadrootsInboundNip22CommentPosition::Nested { parent } => json!({
    502             "type": "nested",
    503             "parent": {
    504                 "tag_index": parent.tag_index(),
    505                 "event_id": parent.event_id().to_hex(),
    506                 "kind_tag_index": parent.kind_tag_index(),
    507                 "kind_raw_tag": parent.kind_raw_tag(),
    508                 "kind": parent.kind(),
    509                 "relay": parent.relay().map(|relay| relay.as_str()),
    510                 "author_hint": parent.author_hint().map(|author| author.to_hex()),
    511                 "author": participant_snapshot(parent.author()),
    512                 "raw_tag": parent.raw_tag(),
    513             },
    514         }),
    515     };
    516     json!({
    517         "contract_id": projection.contract_id(),
    518         "direct": projection.is_direct(),
    519         "root": root,
    520         "position": position,
    521         "mentions": projection
    522             .mentions()
    523             .iter()
    524             .map(participant_snapshot)
    525             .collect::<Vec<_>>(),
    526         "diagnostics": projection
    527             .diagnostics()
    528             .iter()
    529             .map(|diagnostic| json!({
    530                 "code": diagnostic.code(),
    531                 "tag_index": diagnostic.tag_index(),
    532                 "raw_tag": diagnostic.raw_tag(),
    533             }))
    534             .collect::<Vec<_>>(),
    535         "raw_tags": projection.raw_tags(),
    536     })
    537 }
    538 
    539 fn participant_snapshot(participant: &RadrootsInboundNip22Participant) -> Value {
    540     json!({
    541         "tag_index": participant.tag_index(),
    542         "pubkey": participant.pubkey().to_hex(),
    543         "relay": participant.relay().map(|relay| relay.as_str()),
    544         "raw_tag": participant.raw_tag(),
    545     })
    546 }
    547 
    548 fn input_object<'a>(vector: &'a Vector, field: &str) -> &'a serde_json::Map<String, Value> {
    549     vector.input[field]
    550         .as_object()
    551         .unwrap_or_else(|| panic!("{} input.{field} must be an object", vector.id))
    552 }
    553 
    554 fn input_str<'a>(vector: &'a Vector, field: &str) -> &'a str {
    555     vector.input[field]
    556         .as_str()
    557         .unwrap_or_else(|| panic!("{} input.{field} must be a string", vector.id))
    558 }
    559 
    560 fn object_str<'a>(
    561     object: &'a serde_json::Map<String, Value>,
    562     field: &str,
    563     vector_id: &str,
    564 ) -> &'a str {
    565     object[field]
    566         .as_str()
    567         .unwrap_or_else(|| panic!("{vector_id} {field} must be a string"))
    568 }
    569 
    570 fn object_optional_str<'a>(
    571     object: &'a serde_json::Map<String, Value>,
    572     field: &str,
    573     vector_id: &str,
    574 ) -> Option<&'a str> {
    575     match object.get(field) {
    576         None | Some(Value::Null) => None,
    577         Some(value) => Some(
    578             value
    579                 .as_str()
    580                 .unwrap_or_else(|| panic!("{vector_id} {field} must be a string or null")),
    581         ),
    582     }
    583 }
    584 
    585 fn object_u32(object: &serde_json::Map<String, Value>, field: &str, vector_id: &str) -> u32 {
    586     u32::try_from(
    587         object[field]
    588             .as_u64()
    589             .unwrap_or_else(|| panic!("{vector_id} {field} must be an integer")),
    590     )
    591     .unwrap_or_else(|_| panic!("{vector_id} {field} must fit u32"))
    592 }
    593 
    594 fn expected_str<'a>(vector: &'a Vector, field: &str) -> &'a str {
    595     vector.expected[field]
    596         .as_str()
    597         .unwrap_or_else(|| panic!("{} expected.{field} must be a string", vector.id))
    598 }