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 }