focused_availability_projection.rs (46375B)
1 use harvestcircle_domain::{ 2 AvailabilityEventVersion, AvailabilityHeadState, AvailabilityHeadView, 3 AvailabilityListingCoordinate, AvailabilityObservation, AvailabilityUnsupportedReason, 4 AvailabilityVersionView, EventId, Kind0ProfileCandidate, ProfileMetadata, PublicKey, SafeError, 5 SafeErrorCode, UnixTimestamp, 6 }; 7 use harvestcircle_nostr::parse_verified_kind0; 8 use nostr::{Event, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; 9 use radroots_event::envelope::event_head::{ 10 CurrentEventHead, EventHeadCandidate, EventHeadCandidateResult, EventHeadCoordinate, 11 EventHeadDecision, event_head_candidate_for_nip01_event, select_event_head, 12 }; 13 use radroots_event::listing::classified::ClassifiedListingPartition; 14 use radroots_event::wire::{ 15 DEFAULT_EXTRA_MAX_FIELDS, DEFAULT_EXTRA_TOTAL_JSON_MAX_BYTES, DEFAULT_RAW_JSON_MAX_BYTES, 16 DEFAULT_TAG_ELEMENT_MAX_BYTES, Nip01EventWire, 17 }; 18 use radroots_event_codec::admission::deletion::{ 19 RadrootsAdmittedNip09DeletionRequestEvent, RadrootsNip09SuppressionOutcome, 20 RadrootsNip09SuppressionReason, evaluate_nip09_suppression_from_borrowed_requests_v1, 21 verify_and_admit_nip09_deletion_request_event, 22 }; 23 use radroots_event_codec::admission::food_availability::{ 24 RadrootsFoodAvailabilityAdmissionOutcome, admit_verified_food_availability_event, 25 }; 26 use radroots_event_codec::decode::food_availability::RadrootsFoodAvailabilityImageDiagnostic; 27 use radroots_event_codec::verify::{ 28 RadrootsNip01VerificationError, RadrootsSignatureVerifiedEvent, verify_nip01_event, 29 }; 30 31 #[path = "fixtures/availability_admission_v1.rs"] 32 mod admission; 33 #[path = "fixtures/availability_lifecycle_v1.rs"] 34 mod lifecycle; 35 36 const MAX_RECIPE_BYTES: usize = 8 * 1024; 37 const MAX_RECIPE_TAGS: usize = 32; 38 const MAX_RECIPE_ELEMENTS: usize = 128; 39 const MAX_SIGNED_EVENT_BYTES: usize = 16 * 1024; 40 const MAX_LIFECYCLE_EVENT_BYTES: usize = 4 * 1024; 41 const MAX_LIFECYCLE_CORPUS_BYTES: usize = 64 * 1024; 42 const MAX_RETAINED_RECIPE_SOURCE_BYTES: usize = 32 * 1024; 43 const MAX_SOURCE_BYTES: usize = 2048; 44 const MAX_DELETION_REQUESTS: usize = 4096; 45 const OBSERVED_AT: i64 = 1_800_000_500; 46 const SOURCE: &str = "wss://relay.example.test/observations?scope=public"; 47 48 struct PublicFixture { 49 json: String, 50 author: String, 51 } 52 53 fn assert_recipe_source_bounds() { 54 let sources = [ 55 include_str!("fixtures/availability_admission_v1.rs"), 56 include_str!("fixtures/availability_lifecycle_v1.rs"), 57 ]; 58 assert!( 59 sources.iter().map(|source| source.len()).sum::<usize>() 60 <= MAX_RETAINED_RECIPE_SOURCE_BYTES 61 ); 62 for source in sources { 63 for forbidden in ["nsec1", "secret_key", "private_key", "credential"] { 64 assert!( 65 !source.contains(forbidden), 66 "retained recipes contain only public data" 67 ); 68 } 69 } 70 } 71 72 fn sign_bounded_event( 73 keys: &Keys, 74 kind: u16, 75 created_at: u64, 76 content: &str, 77 tags: Vec<Vec<String>>, 78 ) -> Event { 79 assert_recipe_source_bounds(); 80 assert!(tags.len() <= MAX_RECIPE_TAGS); 81 assert!(tags.iter().map(Vec::len).sum::<usize>() <= MAX_RECIPE_ELEMENTS); 82 assert!( 83 content.len() + tags.iter().flatten().map(String::len).sum::<usize>() <= MAX_RECIPE_BYTES 84 ); 85 assert!( 86 tags.iter() 87 .flatten() 88 .all(|value| value.len() <= DEFAULT_TAG_ELEMENT_MAX_BYTES) 89 ); 90 EventBuilder::new(Kind::from(kind), content) 91 .tags( 92 tags.into_iter() 93 .map(|tag| Tag::parse(tag).expect("bounded public raw tag")), 94 ) 95 .custom_created_at(Timestamp::from(created_at)) 96 .sign_with_keys(keys) 97 .expect("isolated public fixture signing") 98 } 99 100 /// The ephemeral signer is discarded before retained public JSON is returned. 101 fn public_fixture_at(recipe: admission::Recipe, kind: u16, created_at: u64) -> PublicFixture { 102 let keys = Keys::generate(); 103 let author = keys.public_key().to_hex(); 104 let event = sign_bounded_event(&keys, kind, created_at, &recipe.content, recipe.tags); 105 drop(keys); 106 let json = event.as_json(); 107 assert!(json.len() <= MAX_SIGNED_EVENT_BYTES); 108 PublicFixture { json, author } 109 } 110 111 fn public_fixture(recipe: admission::Recipe) -> PublicFixture { 112 public_fixture_at(recipe, 30402, admission::CREATED_AT) 113 } 114 115 fn verified(json: &str) -> RadrootsSignatureVerifiedEvent { 116 assert!(json.len() <= MAX_SIGNED_EVENT_BYTES); 117 let wire = Nip01EventWire::parse_json_unverified(json).expect("default-bounded public wire"); 118 verify_nip01_event( 119 wire.into_unverified_envelope() 120 .expect("structurally valid envelope"), 121 ) 122 .expect("real shared ID and signature verification") 123 } 124 125 fn observation() -> AvailabilityObservation { 126 AvailabilityObservation::parse( 127 SOURCE, 128 UnixTimestamp::from_seconds(OBSERVED_AT).expect("local observation time"), 129 ) 130 .expect("bounded pure source reference") 131 } 132 133 fn version_view(json: &str) -> AvailabilityVersionView { 134 AvailabilityVersionView::from_verified(verified(json), json, observation()) 135 .expect("verified version view") 136 } 137 138 fn candidate(event: &RadrootsSignatureVerifiedEvent) -> EventHeadCandidate { 139 match event_head_candidate_for_nip01_event(event.event()) { 140 EventHeadCandidateResult::Candidate(candidate) => candidate, 141 _ => panic!("verified addressable recipe must retain a raw head"), 142 } 143 } 144 145 fn selected(events: &[&RadrootsSignatureVerifiedEvent]) -> CurrentEventHead { 146 assert!(!events.is_empty() && events.len() <= 3); 147 let mut current = None; 148 for event in events { 149 match select_event_head(candidate(event), current.as_ref()) { 150 EventHeadDecision::Applied(next) => current = Some(next), 151 EventHeadDecision::SkippedDuplicate 152 | EventHeadDecision::SkippedOlder 153 | EventHeadDecision::SkippedSameTimestampHigherEventId => {} 154 EventHeadDecision::CoordinateMismatch => { 155 panic!("one raw coordinate per ordering fixture") 156 } 157 } 158 } 159 current.expect("shared selected head") 160 } 161 162 struct LifecycleCorpus { 163 author: String, 164 other_author: String, 165 events: Vec<(&'static str, String)>, 166 } 167 168 /// Both temporary signers are discarded before the bounded public corpus escapes. 169 fn lifecycle_corpus() -> LifecycleCorpus { 170 let owner = Keys::generate(); 171 let other = Keys::generate(); 172 let author = owner.public_key().to_hex(); 173 let other_author = other.public_key().to_hex(); 174 assert_ne!(author, other_author); 175 let mut signed = Vec::<(&'static str, Event)>::new(); 176 for version in &lifecycle::VERSIONS { 177 signed.push(( 178 version.name, 179 sign_bounded_event( 180 if version.other_author { &other } else { &owner }, 181 30402, 182 version.created_at, 183 version.content, 184 lifecycle::listing_tags(version), 185 ), 186 )); 187 } 188 let original = &lifecycle::VERSIONS[0]; 189 signed.push(( 190 "other_kind", 191 sign_bounded_event( 192 &owner, 193 lifecycle::OTHER_ADDRESSABLE_KIND, 194 original.created_at, 195 original.content, 196 lifecycle::listing_tags(original), 197 ), 198 )); 199 let address = format!("30402:{author}:{}", lifecycle::IDENTIFIER); 200 for deletion in &lifecycle::DELETIONS { 201 let mut tags = Vec::new(); 202 if let lifecycle::Target::Event(name) | lifecycle::Target::EventAndAddress(name) = 203 deletion.target 204 { 205 let (_, target) = signed 206 .iter() 207 .find(|(target_name, _)| *target_name == name) 208 .expect("reused lifecycle target exists"); 209 tags.push(vec!["e".into(), target.id.to_hex()]); 210 } 211 if matches!( 212 deletion.target, 213 lifecycle::Target::Address | lifecycle::Target::EventAndAddress(_) 214 ) { 215 tags.push(vec!["a".into(), address.clone()]); 216 } 217 tags.push(vec!["k".into(), "30402".into()]); 218 signed.push(( 219 deletion.name, 220 sign_bounded_event( 221 if deletion.other_author { 222 &other 223 } else { 224 &owner 225 }, 226 5, 227 deletion.created_at, 228 "Public deletion conformance.", 229 tags, 230 ), 231 )); 232 } 233 drop(owner); 234 drop(other); 235 let events: Vec<_> = signed 236 .into_iter() 237 .map(|(name, event)| { 238 let json = event.as_json(); 239 assert!(json.len() <= MAX_LIFECYCLE_EVENT_BYTES); 240 (name, json) 241 }) 242 .collect(); 243 assert_eq!(events.len(), 18); 244 assert!(events.iter().map(|(_, json)| json.len()).sum::<usize>() <= MAX_LIFECYCLE_CORPUS_BYTES); 245 LifecycleCorpus { 246 author, 247 other_author, 248 events, 249 } 250 } 251 252 fn lifecycle_json<'a>(corpus: &'a LifecycleCorpus, name: &str) -> &'a str { 253 &corpus 254 .events 255 .iter() 256 .find(|(event_name, _)| *event_name == name) 257 .expect("named unchanged lifecycle recipe") 258 .1 259 } 260 261 fn deletion(corpus: &LifecycleCorpus, name: &str) -> RadrootsAdmittedNip09DeletionRequestEvent { 262 let wire = Nip01EventWire::parse_json_unverified(lifecycle_json(corpus, name)) 263 .expect("default-bounded deletion wire"); 264 verify_and_admit_nip09_deletion_request_event( 265 wire.into_unverified_envelope().expect("request envelope"), 266 ) 267 .expect("real shared signature verification and deletion admission") 268 } 269 270 fn replace_once(json: &str, original: &str, replacement: &str) -> String { 271 assert_eq!( 272 json.matches(original).count(), 273 1, 274 "unique public fixture token" 275 ); 276 let replaced = json.replacen(original, replacement, 1); 277 assert!(replaced.len() <= DEFAULT_RAW_JSON_MAX_BYTES + 1); 278 replaced 279 } 280 281 fn replace_string_field(json: &str, field: &str, original: &str, replacement: &str) -> String { 282 replace_once( 283 json, 284 &format!("\"{field}\":\"{original}\""), 285 &format!("\"{field}\":\"{replacement}\""), 286 ) 287 } 288 289 fn add_extras(json: &str, extras: &str) -> String { 290 assert!(json.ends_with('}')); 291 assert!(json.len() + extras.len() <= DEFAULT_RAW_JSON_MAX_BYTES); 292 format!("{},{}{}", &json[..json.len() - 1], extras, '}') 293 } 294 295 fn text_at_bytes(unit: &str, size: usize) -> String { 296 unit.repeat(size / unit.len()) + &"x".repeat(size % unit.len()) 297 } 298 299 fn assert_safe(error: SafeError, rejected_input: &str) { 300 assert_eq!(error.code(), SafeErrorCode::InvalidProfileMetadata); 301 for rendered in [error.to_string(), format!("{error:?}")] { 302 assert!(rendered.len() <= 256); 303 assert!(!rendered.contains(rejected_input)); 304 } 305 } 306 307 #[test] 308 fn admitted_view_preserves_exact_version_coordinate_and_signed_times() { 309 let fixture = public_fixture(admission::focused_recipe()); 310 let event = verified(&fixture.json); 311 let admitted = 312 match admit_verified_food_availability_event(event.clone()).expect("real admission") { 313 RadrootsFoodAvailabilityAdmissionOutcome::Admitted(admitted) => admitted, 314 _ => panic!("focused recipe"), 315 }; 316 let view = version_view(&fixture.json); 317 assert_eq!(view.version().event_id(), *event.event().id()); 318 assert_eq!(view.publisher().public_key().to_hex(), fixture.author); 319 assert_eq!(view.created_at(), event.event().created_at()); 320 assert_eq!(view.created_at().as_u64(), admission::CREATED_AT); 321 assert_eq!(view.observation().observed_at().as_seconds(), OBSERVED_AT); 322 assert_eq!(view.observation().source().as_str(), SOURCE); 323 assert_eq!(view.original_json(), fixture.json); 324 assert_eq!(view.raw_coordinate(), &candidate(&event).coordinate); 325 assert_eq!( 326 view.listing_coordinate() 327 .expect("strict app reference") 328 .identifier(), 329 "hcav-incoming-v1" 330 ); 331 assert_eq!(view.focused(), Some(admitted.projection())); 332 assert_eq!(view.unsupported_reason(), None); 333 assert_eq!(view.profile_metadata(), None); 334 335 let wide = public_fixture_at(admission::focused_recipe(), 30402, i64::MAX as u64 + 1); 336 let wide_view = version_view(&wide.json); 337 assert_eq!(wide_view.created_at().as_u64(), i64::MAX as u64 + 1); 338 assert_eq!( 339 wide_view.observation().observed_at().as_seconds(), 340 OBSERVED_AT 341 ); 342 } 343 344 #[test] 345 fn admitted_view_preserves_unknown_quantity_and_exact_normalized_price() { 346 let fixture = public_fixture(admission::focused_recipe()); 347 let view = version_view(&fixture.json); 348 let food = view.focused().expect("focused tolerant projection"); 349 assert_eq!(food.quantity(), None); 350 assert_eq!(food.price().amount(), "3.5"); 351 assert_eq!(food.price().currency().as_str(), "CAD"); 352 assert_eq!(food.price().unit().as_str(), "lb"); 353 assert_eq!(food.status().as_str(), "active"); 354 assert_eq!(food.published_at().as_u64(), 1_800_000_000); 355 assert_eq!(food.content().as_str(), admission::CONTENT); 356 assert_eq!(food.title().as_str(), "Incoming availability conformance"); 357 assert_eq!(food.summary().as_str(), "Public incoming protocol fixture"); 358 assert_eq!(food.location().as_str(), "Protocol test location"); 359 } 360 361 #[test] 362 fn admitted_view_preserves_known_quantity_without_unit_conversion() { 363 for (amount, unit, expected) in [("0012.5000", "lb", "12.5"), ("0.1250", "kg", "0.125")] { 364 let mut recipe = admission::focused_recipe(); 365 recipe 366 .tags 367 .iter_mut() 368 .find(|tag| tag[0] == "radroots:price_unit") 369 .expect("price unit")[1] = unit.into(); 370 recipe 371 .tags 372 .push(vec!["radroots:quantity".into(), amount.into(), unit.into()]); 373 let fixture = public_fixture(recipe); 374 let view = version_view(&fixture.json); 375 let food = view.focused().expect("focused quantity"); 376 let quantity = food.quantity().expect("known quantity"); 377 assert_eq!(quantity.amount(), expected); 378 assert_eq!(quantity.unit().as_str(), unit); 379 assert_eq!(food.price().unit().as_str(), unit); 380 assert_eq!(food.price().amount(), "3.5"); 381 } 382 } 383 384 #[test] 385 fn admitted_view_retains_optional_image_diagnostics_and_original_wire() { 386 let fixture = public_fixture(admission::edited_recipe(&[ 387 admission::Edit::Append(&["image", "https://images.example.test/incoming.webp"]), 388 admission::Edit::Append(&["t", "opaque-public-discovery-tag"]), 389 ])); 390 let exact = format!( 391 " \n{}\t", 392 add_extras( 393 &fixture.json, 394 "\"untrusted_extra\":{\"stock\":999,\"claim\":\"not-signed\"}" 395 ) 396 ); 397 let view = 398 AvailabilityVersionView::from_verified(verified(&fixture.json), &exact, observation()) 399 .expect("exact extras and formatting"); 400 assert_eq!(view.original_json().as_bytes(), exact.as_bytes()); 401 let food = view 402 .focused() 403 .expect("optional image cannot discard listing"); 404 assert_eq!(food.quantity(), None); 405 assert_eq!(food.images().len(), 1); 406 let image = &food.images()[0]; 407 assert_eq!( 408 image.url(), 409 Some("https://images.example.test/incoming.webp") 410 ); 411 assert_eq!(image.dimensions(), None); 412 assert!(!image.qualifies()); 413 assert_eq!( 414 image.raw_tag(), 415 &["image", "https://images.example.test/incoming.webp"] 416 ); 417 assert!( 418 image 419 .diagnostics() 420 .contains(&RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid) 421 ); 422 assert!( 423 image 424 .diagnostics() 425 .contains(&RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing) 426 ); 427 let retained = 428 Nip01EventWire::parse_json_unverified(view.original_json()).expect("retained wire"); 429 assert!( 430 retained 431 .tags 432 .iter() 433 .any(|tag| tag == &["t", "opaque-public-discovery-tag"]) 434 ); 435 assert_eq!(retained.extra.len(), 1); 436 assert_eq!( 437 retained 438 .into_unverified_envelope() 439 .expect("extras have no envelope authority"), 440 *verified(&fixture.json).event() 441 ); 442 } 443 444 #[test] 445 fn profile_failure_does_not_remove_an_admitted_listing_view() { 446 let keys = Keys::generate(); 447 let author = keys.public_key().to_hex(); 448 let recipe = admission::focused_recipe(); 449 let listing = sign_bounded_event( 450 &keys, 451 30402, 452 admission::CREATED_AT, 453 &recipe.content, 454 recipe.tags, 455 ); 456 let malformed_profile = 457 sign_bounded_event(&keys, 0, admission::CREATED_AT, "not profile JSON", vec![]); 458 let overlong_profile = sign_bounded_event( 459 &keys, 460 0, 461 admission::CREATED_AT, 462 &format!("{{\"name\":\"{}\"}}", "p".repeat(129)), 463 vec![], 464 ); 465 drop(keys); 466 let listing_json = listing.as_json(); 467 let view = version_view(&listing_json); 468 let publisher = PublicKey::from_hex(&author).expect("public profile author"); 469 for event in [malformed_profile, overlong_profile] { 470 let json = event.as_json(); 471 assert!(json.len() <= MAX_SIGNED_EVENT_BYTES); 472 assert!(parse_verified_kind0(&json, publisher).is_err()); 473 assert_eq!(view.profile_metadata(), None); 474 assert_eq!( 475 view.focused() 476 .expect("listing survives optional metadata failure") 477 .status() 478 .as_str(), 479 "active" 480 ); 481 assert_eq!(view.original_json(), listing_json); 482 } 483 assert_eq!( 484 view.with_profile(None) 485 .expect("absence remains absence") 486 .profile_metadata(), 487 None 488 ); 489 } 490 491 #[test] 492 fn generic_and_operational_heads_never_become_focused_food() { 493 let generic = public_fixture(admission::recipe_with_tags(&[])); 494 let view = version_view(&generic.json); 495 assert_eq!(view.focused(), None); 496 assert_eq!( 497 view.unsupported_reason(), 498 Some(&AvailabilityUnsupportedReason::Excluded( 499 ClassifiedListingPartition::GenericNip99 500 )) 501 ); 502 for marker in admission::OPERATIONAL_MARKERS { 503 let fixture = public_fixture(admission::recipe_with_tags(&[&[marker]])); 504 let view = version_view(&fixture.json); 505 assert_eq!(view.focused(), None); 506 assert_eq!( 507 view.unsupported_reason(), 508 Some(&AvailabilityUnsupportedReason::Excluded( 509 ClassifiedListingPartition::OperationalListing 510 )) 511 ); 512 let head = 513 AvailabilityHeadView::from_selected(selected(&[&verified(&fixture.json)]), view, &[]) 514 .expect("raw operational head retained"); 515 assert_eq!(head.state(), AvailabilityHeadState::Unsupported); 516 assert_eq!(head.focused(), None); 517 } 518 assert_eq!( 519 admission::FOCUSED_MARKERS, 520 ["radroots:price_unit", "radroots:quantity"] 521 ); 522 } 523 524 #[test] 525 fn ambiguous_and_malformed_verified_heads_remain_unsupported() { 526 for marker in admission::OPERATIONAL_MARKERS { 527 let mut recipe = admission::focused_recipe(); 528 recipe.tags.push(vec![marker.into()]); 529 let fixture = public_fixture(recipe); 530 let view = version_view(&fixture.json); 531 assert_eq!(view.focused(), None); 532 assert_eq!( 533 view.unsupported_reason(), 534 Some(&AvailabilityUnsupportedReason::ProjectionRejected( 535 "food_profile_ambiguous" 536 )) 537 ); 538 } 539 for case in &admission::MALFORMED_CASES { 540 let fixture = public_fixture(admission::edited_recipe(case.edits)); 541 let event = verified(&fixture.json); 542 assert_eq!( 543 admit_verified_food_availability_event(event.clone()).expect_err(case.name), 544 case.expected 545 ); 546 let view = version_view(&fixture.json); 547 assert_eq!(view.version().event_id(), *event.event().id()); 548 assert_eq!(view.raw_coordinate(), &candidate(&event).coordinate); 549 assert_eq!(view.focused(), None, "{}", case.name); 550 assert_eq!( 551 view.unsupported_reason(), 552 Some(&AvailabilityUnsupportedReason::ProjectionRejected( 553 case.code 554 )), 555 "{}", 556 case.name 557 ); 558 } 559 } 560 561 #[test] 562 fn unsupported_head_preserves_empty_missing_and_long_identifiers() { 563 for identifier in [ 564 None, 565 Some(String::new()), 566 Some("d".repeat(4025)), 567 Some("d".repeat(4026)), 568 Some("é".repeat(2048)), 569 Some(" opaque:\0:é ".into()), 570 ] { 571 let mut recipe = admission::focused_recipe(); 572 recipe.tags.retain(|tag| tag[0] != "d"); 573 if let Some(identifier) = &identifier { 574 recipe.tags.insert(0, vec!["d".into(), identifier.clone()]); 575 } 576 let fixture = public_fixture(recipe); 577 let view = version_view(&fixture.json); 578 assert_eq!(view.focused(), None); 579 assert!(matches!( 580 view.unsupported_reason(), 581 Some(AvailabilityUnsupportedReason::ProjectionRejected(_)) 582 )); 583 match view.raw_coordinate() { 584 EventHeadCoordinate::Addressable { 585 kind, 586 pubkey, 587 d_tag, 588 } => { 589 assert_eq!(*kind, 30402); 590 assert_eq!(pubkey.to_hex(), fixture.author); 591 assert_eq!(d_tag, identifier.as_deref().unwrap_or("")); 592 } 593 _ => panic!("raw kind-30402 head"), 594 } 595 let fits_app = identifier 596 .as_ref() 597 .is_some_and(|value| !value.is_empty() && value.len() <= 4025); 598 assert_eq!(view.listing_coordinate().is_some(), fits_app); 599 if let Some(coordinate) = view.listing_coordinate() { 600 assert_eq!( 601 coordinate.identifier(), 602 identifier.as_deref().expect("nonempty app identifier") 603 ); 604 } 605 let head = 606 AvailabilityHeadView::from_selected(selected(&[&verified(&fixture.json)]), view, &[]) 607 .expect("unsupported raw head survives"); 608 assert_eq!(head.state(), AvailabilityHeadState::Unsupported); 609 assert!(head.version().is_some()); 610 assert_eq!(head.focused(), None); 611 } 612 } 613 614 #[test] 615 fn newer_unsupported_head_blocks_older_compatible_view() { 616 let corpus = lifecycle_corpus(); 617 let old = verified(lifecycle_json(&corpus, "old_active")); 618 assert_eq!( 619 version_view(lifecycle_json(&corpus, "old_active")) 620 .focused() 621 .expect("older food") 622 .status() 623 .as_str(), 624 "active" 625 ); 626 for name in ["generic_head", "malformed_head"] { 627 let newer = verified(lifecycle_json(&corpus, name)); 628 for order in [[&old, &newer, &old], [&newer, &old, &newer]] { 629 let current = selected(&order); 630 assert_eq!(current.event_id, *newer.event().id()); 631 let head = AvailabilityHeadView::from_selected( 632 current.clone(), 633 version_view(lifecycle_json(&corpus, name)), 634 &[], 635 ) 636 .expect("selected unsupported version"); 637 assert_eq!(head.state(), AvailabilityHeadState::Unsupported); 638 assert_eq!( 639 head.version() 640 .expect("retained winner") 641 .version() 642 .event_id(), 643 *newer.event().id() 644 ); 645 assert_eq!(head.focused(), None); 646 assert!( 647 AvailabilityHeadView::from_selected( 648 current, 649 version_view(lifecycle_json(&corpus, "old_active")), 650 &[] 651 ) 652 .is_err() 653 ); 654 } 655 } 656 } 657 658 #[test] 659 fn missing_unsupported_and_deleted_states_remain_distinct() { 660 let corpus = lifecycle_corpus(); 661 let focused = version_view(lifecycle_json(&corpus, "newer_sold")); 662 let missing = AvailabilityHeadView::missing( 663 focused 664 .listing_coordinate() 665 .expect("app coordinate") 666 .clone(), 667 ); 668 assert_eq!(missing.state(), AvailabilityHeadState::Missing); 669 assert_eq!(missing.version(), None); 670 assert_eq!(missing.suppression(), None); 671 assert_eq!(missing.focused(), None); 672 let event = verified(lifecycle_json(&corpus, "newer_sold")); 673 let visible = AvailabilityHeadView::from_selected(selected(&[&event]), focused.clone(), &[]) 674 .expect("visible selected version"); 675 assert_eq!(visible.state(), AvailabilityHeadState::Focused); 676 assert_eq!( 677 visible.focused().expect("visible food").status().as_str(), 678 "sold" 679 ); 680 let deleted = AvailabilityHeadView::from_selected( 681 selected(&[&event]), 682 focused, 683 &[deletion(&corpus, "exact_newer")], 684 ) 685 .expect("shared suppression"); 686 assert_eq!(deleted.state(), AvailabilityHeadState::Deleted); 687 assert_eq!(deleted.focused(), None); 688 assert_eq!( 689 deleted 690 .version() 691 .expect("historical food remains") 692 .focused() 693 .expect("historical projection") 694 .status() 695 .as_str(), 696 "sold" 697 ); 698 let generic_json = lifecycle_json(&corpus, "generic_head"); 699 let unsupported = AvailabilityHeadView::from_selected( 700 selected(&[&verified(generic_json)]), 701 version_view(generic_json), 702 &[], 703 ) 704 .expect("unsupported winner"); 705 assert_eq!(unsupported.state(), AvailabilityHeadState::Unsupported); 706 assert_eq!(unsupported.focused(), None); 707 } 708 709 #[test] 710 fn selected_head_mismatch_rejects_an_older_version() { 711 let corpus = lifecycle_corpus(); 712 let old_json = lifecycle_json(&corpus, "old_active"); 713 let new = verified(lifecycle_json(&corpus, "newer_sold")); 714 assert!( 715 AvailabilityHeadView::from_selected(selected(&[&new]), version_view(old_json), &[]) 716 .is_err() 717 ); 718 let actual = selected(&[&verified(old_json)]); 719 let mut wrong_id = actual.clone(); 720 wrong_id.event_id = *new.event().id(); 721 let mut wrong_time = actual.clone(); 722 wrong_time.created_at += 1; 723 let mut mismatches = vec![wrong_id, wrong_time]; 724 for name in ["other_identifier", "other_author", "other_kind"] { 725 let mut wrong_coordinate = actual.clone(); 726 wrong_coordinate.coordinate = 727 candidate(&verified(lifecycle_json(&corpus, name))).coordinate; 728 assert_eq!(wrong_coordinate.event_id, actual.event_id); 729 assert_eq!(wrong_coordinate.created_at, actual.created_at); 730 assert_ne!(wrong_coordinate.coordinate, actual.coordinate); 731 mismatches.push(wrong_coordinate); 732 } 733 assert_eq!(mismatches.len(), 5); 734 for wrong in mismatches { 735 let error = AvailabilityHeadView::from_selected(wrong, version_view(old_json), &[]) 736 .expect_err("all selected head fields bind the exact version"); 737 assert_safe(error, "Public lifecycle conformance."); 738 } 739 assert!(AvailabilityHeadView::from_selected(actual, version_view(old_json), &[]).is_ok()); 740 } 741 742 #[test] 743 fn deleted_view_retains_shared_author_and_cutoff_evidence() { 744 let corpus = lifecycle_corpus(); 745 assert_ne!(corpus.author, corpus.other_author); 746 let target_json = lifecycle_json(&corpus, "at_cutoff"); 747 let target = verified(target_json); 748 let exact = deletion(&corpus, "combined_cutoff"); 749 let address = deletion(&corpus, "address_cutoff"); 750 let forged = deletion(&corpus, "forged_address"); 751 assert_eq!(forged.event().author().to_hex(), corpus.other_author); 752 for permutation in lifecycle::ALL_THREE_PERMUTATIONS { 753 let source = [&exact, &address, &forged]; 754 let requests: Vec<_> = permutation 755 .into_iter() 756 .map(|index| (*source[index]).clone()) 757 .collect(); 758 let expected = 759 evaluate_nip09_suppression_from_borrowed_requests_v1(&target, requests.iter()); 760 let head = AvailabilityHeadView::from_selected( 761 selected(&[&target]), 762 version_view(target_json), 763 &requests, 764 ) 765 .expect("shared evidence retained"); 766 assert_eq!(head.state(), AvailabilityHeadState::Deleted); 767 let decision = head.suppression().expect("canonical decision"); 768 assert_eq!(decision, &expected); 769 assert_eq!( 770 decision.outcome(), 771 RadrootsNip09SuppressionOutcome::Suppressed 772 ); 773 assert_eq!( 774 decision.reason(), 775 RadrootsNip09SuppressionReason::EventIdAndAddressReference 776 ); 777 assert_eq!( 778 decision 779 .event_reference() 780 .expect("exact evidence") 781 .request_id(), 782 exact.event().id() 783 ); 784 let evidence = decision.address_reference().expect("address evidence"); 785 assert_eq!( 786 evidence.coordinate().as_str(), 787 format!("30402:{}:{}", corpus.author, lifecycle::IDENTIFIER) 788 ); 789 assert_eq!(evidence.inclusive_cutoff(), lifecycle::CUTOFF); 790 assert_eq!( 791 evidence.request_id(), 792 std::cmp::min(exact.event().id(), address.event().id()) 793 ); 794 } 795 let new_json = lifecycle_json(&corpus, "newer_sold"); 796 let new = verified(new_json); 797 let forged_only = AvailabilityHeadView::from_selected( 798 selected(&[&new]), 799 version_view(new_json), 800 &[deletion(&corpus, "forged_exact")], 801 ) 802 .expect("request admission is not author authorization"); 803 assert_eq!(forged_only.state(), AvailabilityHeadState::Focused); 804 assert_eq!( 805 forged_only 806 .suppression() 807 .expect("author mismatch evidence") 808 .reason(), 809 RadrootsNip09SuppressionReason::RequestAuthorMismatch 810 ); 811 for (name, expected_state, expected_reason) in [ 812 ( 813 "before_cutoff", 814 AvailabilityHeadState::Deleted, 815 RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff, 816 ), 817 ( 818 "at_cutoff", 819 AvailabilityHeadState::Deleted, 820 RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff, 821 ), 822 ( 823 "after_cutoff", 824 AvailabilityHeadState::Focused, 825 RadrootsNip09SuppressionReason::AddressCutoffPrecedesTarget, 826 ), 827 ] { 828 let json = lifecycle_json(&corpus, name); 829 let head = AvailabilityHeadView::from_selected( 830 selected(&[&verified(json)]), 831 version_view(json), 832 std::slice::from_ref(&address), 833 ) 834 .expect("inclusive shared address cutoff"); 835 assert_eq!(head.state(), expected_state); 836 assert_eq!( 837 head.suppression().expect("cutoff decision").reason(), 838 expected_reason 839 ); 840 } 841 // Dedicated capacity boundary: at most4097 duplicate public requests and16MiB 842 // of admitted fixture inputs; production must borrow this slice, never clone it. 843 let request_bytes = lifecycle_json(&corpus, "address_cutoff").len(); 844 assert!(request_bytes * (MAX_DELETION_REQUESTS + 1) <= 16 * 1024 * 1024); 845 let mut requests = vec![address; MAX_DELETION_REQUESTS]; 846 assert_eq!(requests.len(), 4096); 847 assert_eq!( 848 AvailabilityHeadView::from_selected( 849 selected(&[&target]), 850 version_view(target_json), 851 &requests 852 ) 853 .expect("exact request capacity") 854 .state(), 855 AvailabilityHeadState::Deleted 856 ); 857 requests.push(exact); 858 assert_eq!(requests.len(), 4097); 859 assert_safe( 860 AvailabilityHeadView::from_selected( 861 selected(&[&target]), 862 version_view(target_json), 863 &requests, 864 ) 865 .expect_err("one request beyond capacity"), 866 target_json, 867 ); 868 } 869 870 #[test] 871 fn invalid_signatures_cannot_create_verified_views() { 872 let fixture = public_fixture(admission::focused_recipe()); 873 let wire = Nip01EventWire::parse_json_unverified(&fixture.json).expect("public base wire"); 874 let invalid_signature = replace_string_field(&fixture.json, "sig", &wire.sig, &"0".repeat(128)); 875 let unverified = Nip01EventWire::parse_json_unverified(&invalid_signature) 876 .expect("structurally valid invalid signature") 877 .into_unverified_envelope() 878 .expect("unverified envelope"); 879 assert_eq!( 880 verify_nip01_event(unverified).expect_err("signature gate owns verified typestate"), 881 RadrootsNip01VerificationError::SignatureInvalid 882 ); 883 assert_safe( 884 AvailabilityVersionView::from_verified( 885 verified(&fixture.json), 886 &invalid_signature, 887 observation(), 888 ) 889 .expect_err("invalid signature wire cannot bind another verified event"), 890 &invalid_signature, 891 ); 892 let replacement = if wire.id.starts_with('0') { '1' } else { '0' }; 893 let bad_id = format!("{replacement}{}", &wire.id[1..]); 894 let invalid_id = replace_string_field(&fixture.json, "id", &wire.id, &bad_id); 895 let envelope = Nip01EventWire::parse_json_unverified(&invalid_id) 896 .expect("invalid ID is not structurally trusted") 897 .into_unverified_envelope() 898 .expect("unverified envelope"); 899 assert!(matches!( 900 verify_nip01_event(envelope), 901 Err(RadrootsNip01VerificationError::IdMismatch { .. }) 902 )); 903 } 904 905 #[test] 906 fn original_wire_must_match_the_verified_event() { 907 let fixture = public_fixture(admission::focused_recipe()); 908 let neighbor = public_fixture(admission::focused_recipe()); 909 let event = verified(&fixture.json); 910 let wire = Nip01EventWire::parse_json_unverified(&fixture.json).expect("public wire fields"); 911 let neighbor_wire = Nip01EventWire::parse_json_unverified(&neighbor.json) 912 .expect("distinct valid public fields"); 913 let mismatches = [ 914 replace_string_field(&fixture.json, "id", &wire.id, &neighbor_wire.id), 915 replace_string_field(&fixture.json, "pubkey", &wire.pubkey, &neighbor.author), 916 replace_once( 917 &fixture.json, 918 &format!("\"created_at\":{}", admission::CREATED_AT), 919 "\"created_at\":1800000101", 920 ), 921 replace_once(&fixture.json, "\"kind\":30402", "\"kind\":30023"), 922 replace_once( 923 &fixture.json, 924 "[\"status\",\"active\"]", 925 "[\"status\",\"sold\"]", 926 ), 927 replace_string_field( 928 &fixture.json, 929 "content", 930 &wire.content, 931 "Different public content.", 932 ), 933 replace_string_field(&fixture.json, "sig", &wire.sig, &neighbor_wire.sig), 934 ]; 935 for mismatch in &mismatches { 936 let parsed = Nip01EventWire::parse_json_unverified(mismatch) 937 .expect("each altered field stays default structurally valid") 938 .into_unverified_envelope() 939 .expect("unverified altered envelope"); 940 assert_ne!(&parsed, event.event()); 941 assert_safe( 942 AvailabilityVersionView::from_verified(event.clone(), mismatch, observation()) 943 .expect_err("full envelope equality required"), 944 mismatch, 945 ); 946 } 947 assert_eq!(mismatches.len(), 7); 948 for extras in ["\"untrusted\":true", "\"untrusted\":false"] { 949 let exact = format!("\n {} \t", add_extras(&fixture.json, extras)); 950 let retained = AvailabilityVersionView::from_verified(event.clone(), &exact, observation()) 951 .expect("unknown extras and formatting remain unauthenticated"); 952 assert_eq!(retained.original_json(), exact); 953 assert_eq!(retained.version().event_id(), *event.event().id()); 954 assert_eq!( 955 retained 956 .focused() 957 .expect("extras cannot change quantity") 958 .quantity(), 959 None 960 ); 961 } 962 } 963 964 #[test] 965 fn view_wire_and_source_retention_obey_shared_byte_limits() { 966 let mut recipe = admission::focused_recipe(); 967 recipe.content = "Public wire byte boundary é🥕e\u{301}.".into(); 968 let fixture = public_fixture(recipe); 969 assert!(fixture.json.chars().count() < fixture.json.len()); 970 let event = verified(&fixture.json); 971 assert_eq!(DEFAULT_RAW_JSON_MAX_BYTES, 256 * 1024); 972 // Dedicated raw-wire exception to the16KiB signed-fixture bound: whitespace 973 // padding creates exactly256KiB and256KiB+1 without enlarging signed fields. 974 let exact = format!( 975 "{}{}", 976 fixture.json, 977 " ".repeat(DEFAULT_RAW_JSON_MAX_BYTES - fixture.json.len()) 978 ); 979 assert_eq!(exact.len(), DEFAULT_RAW_JSON_MAX_BYTES); 980 assert_eq!( 981 AvailabilityVersionView::from_verified(event.clone(), &exact, observation()) 982 .expect("exact raw-wire capacity") 983 .original_json() 984 .as_bytes(), 985 exact.as_bytes() 986 ); 987 let over = exact + " "; 988 assert_eq!(over.len(), DEFAULT_RAW_JSON_MAX_BYTES + 1); 989 assert_safe( 990 AvailabilityVersionView::from_verified(event.clone(), &over, observation()) 991 .expect_err("raw capacity plus one"), 992 "hcav-incoming-v1", 993 ); 994 let invalid_tags = replace_once(&fixture.json, "\"tags\":[", "\"tags\":[[0],"); 995 let overlong_tag = replace_once( 996 &fixture.json, 997 "[\"status\",\"active\"]", 998 &format!( 999 "[\"status\",\"{}\"]", 1000 "x".repeat(DEFAULT_TAG_ELEMENT_MAX_BYTES + 1) 1001 ), 1002 ); 1003 let too_many_fields = (0..=DEFAULT_EXTRA_MAX_FIELDS) 1004 .map(|index| format!("\"extra_{index}\":0")) 1005 .collect::<Vec<_>>() 1006 .join(","); 1007 let oversized_extra = format!( 1008 "\"extra\":\"{}\"", 1009 "x".repeat(DEFAULT_EXTRA_TOTAL_JSON_MAX_BYTES + 1) 1010 ); 1011 for invalid in [ 1012 invalid_tags, 1013 overlong_tag, 1014 add_extras(&fixture.json, &too_many_fields), 1015 add_extras(&fixture.json, &oversized_extra), 1016 ] { 1017 assert!(invalid.len() <= DEFAULT_RAW_JSON_MAX_BYTES); 1018 assert!( 1019 Nip01EventWire::parse_json_unverified(&invalid).is_err(), 1020 "shared default structural boundary" 1021 ); 1022 assert_safe( 1023 AvailabilityVersionView::from_verified(event.clone(), &invalid, observation()) 1024 .expect_err("shared limits precede retention"), 1025 &invalid, 1026 ); 1027 } 1028 let other_kind = public_fixture_at( 1029 admission::focused_recipe(), 1030 lifecycle::OTHER_ADDRESSABLE_KIND, 1031 admission::CREATED_AT, 1032 ); 1033 assert_safe( 1034 AvailabilityVersionView::from_verified( 1035 verified(&other_kind.json), 1036 &other_kind.json, 1037 observation(), 1038 ) 1039 .expect_err("view only supports kind30402"), 1040 &other_kind.json, 1041 ); 1042 for unit in ["x", "é", "🥕", "e\u{301}"] { 1043 let prefix = "wss://relay.example.test/source/"; 1044 let source = prefix.to_owned() + &text_at_bytes(unit, MAX_SOURCE_BYTES - prefix.len()); 1045 assert_eq!(source.len(), MAX_SOURCE_BYTES); 1046 let observed = AvailabilityObservation::parse(&source, UnixTimestamp::UNIX_EPOCH) 1047 .expect("exact UTF-8 source byte bound"); 1048 assert_eq!(observed.source().as_str().as_bytes(), source.as_bytes()); 1049 let over = source + "x"; 1050 assert_eq!(over.len(), MAX_SOURCE_BYTES + 1); 1051 assert_safe( 1052 AvailabilityObservation::parse(&over, UnixTimestamp::UNIX_EPOCH) 1053 .expect_err("source byte bound plus one"), 1054 &over, 1055 ); 1056 } 1057 } 1058 1059 #[test] 1060 fn view_errors_never_echo_untrusted_input() { 1061 const PAYLOAD: &str = "private-debug-payload-marker"; 1062 let mut recipe = admission::focused_recipe(); 1063 recipe.content = PAYLOAD.into(); 1064 recipe 1065 .tags 1066 .iter_mut() 1067 .find(|tag| tag[0] == "d") 1068 .expect("identifier")[1] = format!("{PAYLOAD}:\0 "); 1069 let fixture = public_fixture(recipe); 1070 let source = format!("wss://relay.example.test/{PAYLOAD}?metadata={PAYLOAD}"); 1071 let observation = AvailabilityObservation::parse(&source, UnixTimestamp::UNIX_EPOCH) 1072 .expect("public opaque source"); 1073 let version = AvailabilityVersionView::from_verified( 1074 verified(&fixture.json), 1075 &fixture.json, 1076 observation.clone(), 1077 ) 1078 .expect("unsupported evidence view"); 1079 let metadata = ProfileMetadata::new( 1080 Some(PAYLOAD.into()), 1081 None, 1082 None, 1083 Some(PAYLOAD.into()), 1084 Some(format!("https://images.example.test/{PAYLOAD}")), 1085 ) 1086 .expect("bounded display fields"); 1087 let candidate = Kind0ProfileCandidate::new( 1088 EventId::from_bytes([7; 32]), 1089 PublicKey::from_hex(&fixture.author).expect("author"), 1090 UnixTimestamp::UNIX_EPOCH, 1091 metadata, 1092 ); 1093 let attached = version 1094 .with_profile(Some(&candidate)) 1095 .expect("caller-associated display metadata"); 1096 let head = AvailabilityHeadView::from_selected( 1097 selected(&[&verified(&fixture.json)]), 1098 attached.clone(), 1099 &[], 1100 ) 1101 .expect("unsupported head"); 1102 let missing = AvailabilityHeadView::missing( 1103 AvailabilityListingCoordinate::parse(&format!("30402:{}:{PAYLOAD}", fixture.author)) 1104 .expect("opaque missing reference"), 1105 ); 1106 for debug in [ 1107 format!("{observation:?}"), 1108 format!("{version:?}"), 1109 format!("{attached:?}"), 1110 format!("{head:?}"), 1111 format!("{missing:?}"), 1112 ] { 1113 assert!(debug.len() <= 1024, "bounded safe summary"); 1114 for sensitive in [PAYLOAD, "wss://", "https://", "\"tags\"", "\"sig\""] { 1115 assert!( 1116 !debug.contains(sensitive), 1117 "debug cannot expose raw payloads" 1118 ); 1119 } 1120 } 1121 for source in [ 1122 format!("https://relay.example.test/{PAYLOAD}"), 1123 format!("wss://user:{PAYLOAD}@relay.example.test"), 1124 format!("wss://relay.example.test/#{PAYLOAD}"), 1125 format!("wss://relay.example.test/\n{PAYLOAD}"), 1126 ] { 1127 assert_safe( 1128 AvailabilityObservation::parse(&source, UnixTimestamp::UNIX_EPOCH) 1129 .expect_err("shared source validation returns static errors"), 1130 PAYLOAD, 1131 ); 1132 } 1133 let malformed = format!("{{{PAYLOAD}"); 1134 assert_safe( 1135 AvailabilityVersionView::from_verified(verified(&fixture.json), &malformed, observation) 1136 .expect_err("malformed wire safe error"), 1137 PAYLOAD, 1138 ); 1139 } 1140 1141 #[test] 1142 fn optional_profile_attachment_checks_publisher_and_preserves_missing_fields() { 1143 let fixture = public_fixture(admission::focused_recipe()); 1144 let view = version_view(&fixture.json); 1145 assert_eq!(view.profile_metadata(), None); 1146 let metadata = ProfileMetadata::new(None, Some(" Caller display ".into()), None, None, None) 1147 .expect("bounded optional display metadata"); 1148 // This constructor has no signature typestate. The association proves only 1149 // matching author and bounded display data, not verified profile or ownership. 1150 let candidate = Kind0ProfileCandidate::new( 1151 EventId::from_bytes([9; 32]), 1152 PublicKey::from_hex(&fixture.author).expect("listing author"), 1153 UnixTimestamp::UNIX_EPOCH, 1154 metadata.clone(), 1155 ); 1156 let attached = view 1157 .with_profile(Some(&candidate)) 1158 .expect("same-author caller display metadata"); 1159 assert_eq!(attached.profile_metadata(), Some(&metadata)); 1160 let attached_metadata = attached.profile_metadata().expect("attached fields"); 1161 assert_eq!(attached_metadata.display_name(), Some("Caller display")); 1162 assert_eq!(attached_metadata.name(), None); 1163 assert_eq!(attached_metadata.nip05(), None); 1164 assert_eq!(attached_metadata.about(), None); 1165 assert_eq!(attached_metadata.picture(), None); 1166 assert_eq!(view.profile_metadata(), None, "immutable original"); 1167 assert_eq!(attached.version(), view.version()); 1168 assert_eq!(attached.focused(), view.focused()); 1169 assert_eq!(attached.original_json(), view.original_json()); 1170 assert_eq!( 1171 attached 1172 .with_profile(None) 1173 .expect("explicit absent optional metadata") 1174 .profile_metadata(), 1175 None 1176 ); 1177 let other = public_fixture(admission::focused_recipe()); 1178 assert_ne!(other.author, fixture.author); 1179 let wrong = Kind0ProfileCandidate::new( 1180 EventId::from_bytes([8; 32]), 1181 PublicKey::from_hex(&other.author).expect("other public author"), 1182 UnixTimestamp::UNIX_EPOCH, 1183 metadata, 1184 ); 1185 assert_safe( 1186 view.with_profile(Some(&wrong)) 1187 .expect_err("author mismatch before metadata copy"), 1188 "Caller display", 1189 ); 1190 assert_eq!( 1191 view.focused() 1192 .expect("failed optional attachment preserves listing") 1193 .status() 1194 .as_str(), 1195 "active" 1196 ); 1197 assert_eq!( 1198 view.version(), 1199 AvailabilityEventVersion::from_canonical(*verified(&fixture.json).event().id()) 1200 ); 1201 } 1202 1203 #[test] 1204 fn observation_preserves_bounded_source_reference_and_observation_time() { 1205 let source = "wss://Relay.Example.test:443/path?region=ca-bc"; 1206 let local = UnixTimestamp::from_seconds(i64::MAX).expect("local timestamp upper bound"); 1207 let observation = AvailabilityObservation::parse(source, local) 1208 .expect("pure shared validation preserves supplied spelling"); 1209 assert_eq!(observation.source().as_str(), source); 1210 assert_eq!(observation.observed_at(), local); 1211 assert_ne!( 1212 observation.source().as_str(), 1213 "wss://relay.example.test/path?region=ca-bc" 1214 ); 1215 let empty_time = 1216 AvailabilityObservation::parse("ws://127.0.0.1:8080/isolated", UnixTimestamp::UNIX_EPOCH) 1217 .expect("provenance is not endpoint authorization"); 1218 assert_eq!(empty_time.observed_at().as_seconds(), 0); 1219 for invalid in [ 1220 "", 1221 "http://relay.example.test", 1222 "WSS://relay.example.test", 1223 " wss://relay.example.test", 1224 "wss://", 1225 "wss://relay.example.test:0", 1226 ] { 1227 let error = AvailabilityObservation::parse(invalid, local) 1228 .expect_err("delegate selected shared pure source validation"); 1229 assert_eq!(error.code(), SafeErrorCode::InvalidProfileMetadata); 1230 assert!(error.to_string().len() <= 128); 1231 } 1232 }