lib

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

commit a92da1b808d0f7455296cd62a1eeb17b41ff3dcc
parent f957017778faa41e47274b03b51d4b6b07232b37
Author: triesap <tyson@radroots.org>
Date:   Sun, 21 Jun 2026 19:32:15 +0000

events: expand event model coverage

- Cover draft construction, signed-event conversion, exact-match validation, and ordered mismatch errors.

- Exercise ID wrapper trait, serde, coordinate, relay, and parse-error paths.

- Add contract tag-helper and event-head selection coverage for branch gates.

- Validate with radroots_events test/check/diff and policy coverage gate.

Diffstat:
Mcrates/events/src/contract.rs | 35+++++++++++++++++++++++++++++++++++
Mcrates/events/src/draft.rs | 240++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/events/src/event_head.rs | 53+++++++++++++++++++++++------------------------------
Mcrates/events/src/ids.rs | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 417 insertions(+), 49 deletions(-)

diff --git a/crates/events/src/contract.rs b/crates/events/src/contract.rs @@ -2655,6 +2655,7 @@ mod tests { event_contract("radroots.list_set.member_of.farms.v1").map(|contract| contract.kind), Some(KIND_LIST_SET_GENERIC) ); + assert!(event_contracts_for_kind(999_999).is_empty()); } #[test] @@ -2718,6 +2719,40 @@ mod tests { } #[test] + fn tag_helpers_cover_missing_names_and_cardinality_mismatches() { + let tags = vec![ + vec!["p".to_owned(), "counterparty".to_owned()], + vec!["d".to_owned()], + ]; + + assert_eq!(tag_value(&tags, "d"), None); + assert_eq!(tag_value(&tags, "p"), Some("counterparty")); + + let malformed = [ + tag( + "d", + RadrootsTagCardinality::OptionalOne, + RadrootsTagSemantic::Identifier, + RadrootsTagValueType::DTag, + true, + ), + tag( + "p", + RadrootsTagCardinality::RequiredOne, + RadrootsTagSemantic::Counterparty, + RadrootsTagValueType::PublicKey, + true, + ), + ]; + + assert!( + !malformed.iter().any( + |tag| tag.name == "d" && tag.cardinality == RadrootsTagCardinality::RequiredOne + ) + ); + } + + #[test] fn relay_indexed_tags_are_single_letter() { for contract in all_event_contracts() .iter() diff --git a/crates/events/src/draft.rs b/crates/events/src/draft.rs @@ -385,6 +385,32 @@ mod tests { core::iter::repeat_n(character, 64).collect() } + fn signed_event_for_draft(draft: &RadrootsFrozenEventDraft) -> RadrootsSignedNostrEvent { + RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { + id: draft.expected_event_id.clone(), + pubkey: draft.expected_pubkey.clone(), + created_at: draft.created_at, + kind: draft.kind, + tags: draft.tags.clone(), + content: draft.content.clone(), + sig: "b".repeat(128), + raw_json: "{}".to_owned(), + }) + .expect("signed event") + } + + fn post_draft() -> RadrootsFrozenEventDraft { + RadrootsFrozenEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + 1_700_000_000, + vec![vec!["t".to_owned(), "soil".to_owned()]], + "hello", + "a".repeat(64), + ) + .expect("draft") + } + #[test] fn frozen_draft_computes_expected_event_id() { let draft = RadrootsFrozenEventDraft::new( @@ -477,6 +503,17 @@ mod tests { mismatch, RadrootsDraftError::ContractKindMismatch { .. } )); + + let invalid_pubkey = RadrootsFrozenEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + 1, + Vec::new(), + "", + "not-hex", + ) + .expect_err("invalid pubkey"); + assert!(matches!(invalid_pubkey, RadrootsDraftError::IdParse(_))); } #[test] @@ -500,33 +537,198 @@ mod tests { } #[test] - fn signed_event_validation_rejects_draft_mismatches() { - let draft = RadrootsFrozenEventDraft::new( - "radroots.social.post.v1", - KIND_POST, - 1_700_000_000, - vec![vec!["t".to_owned(), "soil".to_owned()]], - "hello", - "a".repeat(64), - ) - .expect("draft"); - let signed = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { - id: draft.expected_event_id.clone(), - pubkey: draft.expected_pubkey.clone(), - created_at: draft.created_at, - kind: draft.kind, - tags: draft.tags.clone(), - content: "changed".to_owned(), - sig: "b".repeat(128), + fn signed_event_from_nostr_event_validates_parts() { + let event = RadrootsNostrEvent { + id: hex_64('1'), + author: hex_64('2'), + created_at: 42, + kind: KIND_POST, + tags: vec![vec!["t".to_owned(), "soil".to_owned()]], + content: "hello".to_owned(), + sig: "3".repeat(128), + }; + let signed = RadrootsSignedNostrEvent::from_event(event, "{\"id\":\"fixture\"}") + .expect("signed event"); + + assert_eq!(signed.id, hex_64('1')); + assert_eq!(signed.pubkey, hex_64('2')); + assert_eq!(signed.sig, "3".repeat(128)); + assert_eq!(signed.raw_json, "{\"id\":\"fixture\"}"); + + let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { + id: "not-hex".to_owned(), + pubkey: hex_64('e'), + created_at: 10, + kind: KIND_POST, + tags: Vec::new(), + content: String::new(), + sig: "f".repeat(128), + raw_json: "{}".to_owned(), + }) + .expect_err("invalid id"); + assert!(matches!(invalid, RadrootsDraftError::IdParse(_))); + + let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { + id: hex_64('d'), + pubkey: "not-hex".to_owned(), + created_at: 10, + kind: KIND_POST, + tags: Vec::new(), + content: String::new(), + sig: "f".repeat(128), + raw_json: "{}".to_owned(), + }) + .expect_err("invalid pubkey"); + assert!(matches!(invalid, RadrootsDraftError::IdParse(_))); + + let invalid = RadrootsSignedNostrEvent::new(RadrootsSignedNostrEventParts { + id: hex_64('d'), + pubkey: hex_64('e'), + created_at: 10, + kind: KIND_POST, + tags: Vec::new(), + content: String::new(), + sig: "not-hex".to_owned(), raw_json: "{}".to_owned(), }) - .expect("signed"); + .expect_err("invalid sig"); + assert!(matches!(invalid, RadrootsDraftError::IdParse(_))); + } + + #[test] + fn signed_event_validation_accepts_exact_draft_match() { + let draft = post_draft(); + let signed = signed_event_for_draft(&draft); + + validate_signed_nostr_event_matches_draft(&signed, &draft).expect("valid signed event"); + } + + #[test] + fn signed_event_validation_rejects_draft_mismatches() { + let draft = post_draft(); + + let mut signed = signed_event_for_draft(&draft); + signed.pubkey = hex_64('c'); + let error = + validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); + assert!(matches!( + error, + RadrootsDraftError::SignedEventPubkeyMismatch { .. } + )); + + let mut signed = signed_event_for_draft(&draft); + signed.id = hex_64('d'); + let error = + validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); + assert!(matches!( + error, + RadrootsDraftError::SignedEventIdMismatch { .. } + )); + + let mut signed = signed_event_for_draft(&draft); + signed.created_at += 1; + let error = + validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); + assert!(matches!( + error, + RadrootsDraftError::SignedEventCreatedAtMismatch { .. } + )); + + let mut signed = signed_event_for_draft(&draft); + signed.kind = KIND_PROFILE; + let error = + validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); + assert!(matches!( + error, + RadrootsDraftError::SignedEventKindMismatch { .. } + )); + let mut signed = signed_event_for_draft(&draft); + signed.tags.push(vec!["p".to_owned(), hex_64('e')]); + let error = + validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); + assert!(matches!( + error, + RadrootsDraftError::SignedEventTagsMismatch { .. } + )); + + let mut signed = signed_event_for_draft(&draft); + signed.content = "changed".to_owned(); let error = validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); assert!(matches!( error, RadrootsDraftError::SignedEventContentMismatch { .. } )); + + let mut draft = post_draft(); + draft.expected_event_id = hex_64('f'); + let signed = signed_event_for_draft(&draft); + let error = + validate_signed_nostr_event_matches_draft(&signed, &draft).expect_err("mismatch"); + assert!(matches!( + error, + RadrootsDraftError::SignedEventComputedIdMismatch { .. } + )); + } + + #[test] + fn draft_errors_format_all_variants() { + let errors = [ + RadrootsDraftError::UnknownContract("missing".to_owned()), + RadrootsDraftError::ContractKindMismatch { + contract_id: "radroots.social.post.v1".to_owned(), + expected_kind: KIND_POST, + actual_kind: KIND_PROFILE, + }, + RadrootsDraftError::SignedEventPubkeyMismatch { + expected_pubkey: hex_64('a'), + actual_pubkey: hex_64('b'), + }, + RadrootsDraftError::SignedEventIdMismatch { + expected_event_id: hex_64('c'), + actual_event_id: hex_64('d'), + }, + RadrootsDraftError::SignedEventCreatedAtMismatch { + expected_created_at: 1, + actual_created_at: 2, + }, + RadrootsDraftError::SignedEventKindMismatch { + expected_kind: KIND_POST, + actual_kind: KIND_PROFILE, + }, + RadrootsDraftError::SignedEventTagsMismatch { + expected_len: 1, + actual_len: 2, + }, + RadrootsDraftError::SignedEventContentMismatch { + expected_len: 5, + actual_len: 7, + }, + RadrootsDraftError::SignedEventComputedIdMismatch { + expected_event_id: hex_64('e'), + computed_event_id: hex_64('f'), + }, + RadrootsDraftError::from(RadrootsIdParseError::Empty), + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + } + + let json_error = serde_json::from_str::<String>("{").expect_err("json error"); + let error = RadrootsDraftError::from(json_error); + assert!( + error + .to_string() + .contains("json string serialization failed") + ); + } + + #[test] + fn event_id_computation_rejects_invalid_pubkeys() { + let error = + compute_nip01_event_id("not-hex", 1, KIND_POST, &[], "").expect_err("invalid pubkey"); + assert!(matches!(error, RadrootsDraftError::IdParse(_))); } } diff --git a/crates/events/src/event_head.rs b/crates/events/src/event_head.rs @@ -223,10 +223,14 @@ mod tests { } fn candidate(id: char, created_at: u32) -> RadrootsEventHeadCandidate { - match event_head_candidate_for_class( + expect_candidate(event_head_candidate_for_class( &event(10002, &hex_64(id), &hex_64('a'), created_at, Vec::new()), RadrootsEventClass::Replaceable, - ) { + )) + } + + fn expect_candidate(result: RadrootsEventHeadCandidateResult) -> RadrootsEventHeadCandidate { + match result { RadrootsEventHeadCandidateResult::Candidate(candidate) => candidate, other => panic!("expected candidate: {other:?}"), } @@ -248,11 +252,10 @@ mod tests { #[test] fn replaceable_events_use_kind_and_pubkey_coordinates() { let event = event(10002, &hex_64('1'), &hex_64('a'), 5, Vec::new()); - let RadrootsEventHeadCandidateResult::Candidate(candidate) = - event_head_candidate_for_class(&event, RadrootsEventClass::Replaceable) - else { - panic!("expected candidate") - }; + let candidate = expect_candidate(event_head_candidate_for_class( + &event, + RadrootsEventClass::Replaceable, + )); assert_eq!( candidate.coordinate, RadrootsEventHeadCoordinate::Replaceable { @@ -272,11 +275,10 @@ mod tests { 7, vec![vec![TAG_D.to_string(), "article-1".to_string()]], ); - let RadrootsEventHeadCandidateResult::Candidate(candidate) = - event_head_candidate_for_class(&event, RadrootsEventClass::Addressable) - else { - panic!("expected candidate") - }; + let candidate = expect_candidate(event_head_candidate_for_class( + &event, + RadrootsEventClass::Addressable, + )); assert_eq!( candidate.coordinate, RadrootsEventHeadCoordinate::Addressable { @@ -332,6 +334,10 @@ mod tests { let current: RadrootsCurrentEventHead = candidate('3', 10).into(); assert!(matches!( + select_event_head(candidate('1', 1), None), + RadrootsEventHeadDecision::Applied(_) + )); + assert!(matches!( select_event_head(candidate('4', 11), Some(&current)), RadrootsEventHeadDecision::Applied(_) )); @@ -366,9 +372,7 @@ mod tests { ), RadrootsEventClass::Addressable, ); - let RadrootsEventHeadCandidateResult::Candidate(other) = other else { - panic!("expected candidate") - }; + let other = expect_candidate(other); assert_eq!( select_event_head(other, Some(&current)), RadrootsEventHeadDecision::CoordinateMismatch @@ -378,11 +382,7 @@ mod tests { #[test] fn contract_bridge_uses_replaceable_event_classes() { let event = event(KIND_FOLLOW, &hex_64('1'), &hex_64('a'), 1, Vec::new()); - let RadrootsEventHeadCandidateResult::Candidate(candidate) = - event_head_candidate_for_event(&event).expect("contract") - else { - panic!("expected candidate") - }; + let candidate = expect_candidate(event_head_candidate_for_event(&event).expect("contract")); assert_eq!( candidate.coordinate, RadrootsEventHeadCoordinate::Replaceable { @@ -401,11 +401,7 @@ mod tests { 1, vec![vec![TAG_D.to_string(), "member_of.farms".to_string()]], ); - let RadrootsEventHeadCandidateResult::Candidate(candidate) = - event_head_candidate_for_event(&event).expect("contract") - else { - panic!("expected candidate") - }; + let candidate = expect_candidate(event_head_candidate_for_event(&event).expect("contract")); assert_eq!( candidate.coordinate, RadrootsEventHeadCoordinate::Addressable { @@ -426,11 +422,8 @@ mod tests { Vec::new(), r#"{"name":"Alice"}"#, ); - let RadrootsEventHeadCandidateResult::Candidate(candidate) = - event_head_candidate_for_event(&profile).expect("profile contract") - else { - panic!("expected candidate") - }; + let candidate = + expect_candidate(event_head_candidate_for_event(&profile).expect("profile contract")); assert_eq!( candidate.coordinate, RadrootsEventHeadCoordinate::Replaceable { diff --git a/crates/events/src/ids.rs b/crates/events/src/ids.rs @@ -324,6 +324,49 @@ fn validate_visible_token(value: &str, max_len: usize) -> Result<String, Radroot mod tests { use super::*; + macro_rules! assert_identifier_impls { + ($ty:ty, $value:expr) => {{ + let value = $value.to_owned(); + let value = value.as_str(); + let id = <$ty>::parse(value).expect("parse"); + + assert_eq!(id.as_str(), value); + assert_eq!(id.as_ref(), value); + assert_eq!(&*id, value); + assert_eq!(<$ty as core::borrow::Borrow<str>>::borrow(&id), value); + assert_eq!(id.to_string(), value); + assert_eq!( + <$ty as core::str::FromStr>::from_str(value).expect("from str"), + id + ); + assert_eq!( + <$ty as TryFrom<&str>>::try_from(value).expect("try from str"), + id + ); + assert_eq!( + <$ty as TryFrom<String>>::try_from(value.to_owned()).expect("try from string"), + id + ); + assert_eq!(id, value); + assert_eq!(value, id); + assert_eq!(id, value.to_owned()); + assert_eq!(value.to_owned(), id); + + let id = <$ty>::parse(value).expect("parse"); + let converted: String = String::from(id.clone()); + assert_eq!(converted, value); + assert_eq!(id.into_string(), value.to_owned()); + + #[cfg(feature = "serde")] + { + let id = <$ty>::parse(value).expect("parse"); + let encoded = serde_json::to_string(&id).expect("serialize"); + let decoded: $ty = serde_json::from_str(&encoded).expect("deserialize"); + assert_eq!(decoded.as_str(), value); + } + }}; + } + fn hex_64(character: char) -> String { core::iter::repeat_n(character, 64).collect() } @@ -354,6 +397,27 @@ mod tests { } #[test] + fn id_parse_errors_have_stable_display_messages() { + let errors = [ + RadrootsIdParseError::Empty, + RadrootsIdParseError::InvalidFormat, + RadrootsIdParseError::InvalidLength { + expected: 64, + actual: 7, + }, + RadrootsIdParseError::InvalidCharacter, + RadrootsIdParseError::TooLong { + max: 128, + actual: 129, + }, + ]; + + for error in errors { + assert!(!error.to_string().is_empty()); + } + } + + #[test] fn signatures_require_128_hex_chars() { let signature = RadrootsEventSignature::parse(hex_128('B')).expect("signature"); assert_eq!(signature.as_str(), "b".repeat(128)); @@ -404,6 +468,20 @@ mod tests { actual: 7 } ); + assert_eq!( + RadrootsAddressableCoordinate::parse("30402").unwrap_err(), + RadrootsIdParseError::InvalidFormat + ); + assert_eq!( + RadrootsAddressableCoordinate::parse(format!("bad:{}:listing-1", hex_64('a'))) + .unwrap_err(), + RadrootsIdParseError::InvalidFormat + ); + assert_eq!( + RadrootsAddressableCoordinate::parse(format!("30402:{}:bad d", hex_64('0'))) + .unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); } #[test] @@ -451,6 +529,13 @@ mod tests { .as_str(), "digest-1" ); + assert_eq!( + RadrootsEconomicsDigest::parse("sha256:not-hex").unwrap_err(), + RadrootsIdParseError::InvalidLength { + expected: 64, + actual: 7 + } + ); } #[test] @@ -461,6 +546,59 @@ mod tests { assert_eq!(parsed.as_str(), hex_64('d')); } + #[test] + fn validated_identifier_wrappers_expose_consistent_traits() { + let addressable = format!("30402:{}:listing-1", hex_64('0')); + + assert_identifier_impls!(RadrootsPublicKey, hex_64('a').as_str()); + assert_identifier_impls!(RadrootsEventId, hex_64('b').as_str()); + assert_identifier_impls!(RadrootsEventSignature, hex_128('c').as_str()); + assert_identifier_impls!(RadrootsDTag, "listing-1"); + assert_identifier_impls!(RadrootsAddressableCoordinate, addressable.as_str()); + assert_identifier_impls!(RadrootsListingAddress, addressable.as_str()); + assert_identifier_impls!(RadrootsOrderId, "order-1"); + assert_identifier_impls!(RadrootsOrderRevisionId, "revision-1"); + assert_identifier_impls!(RadrootsOrderQuoteId, "quote-1"); + assert_identifier_impls!(RadrootsInventoryBinId, "bin-1"); + assert_identifier_impls!(RadrootsEconomicsDigest, "digest-1"); + assert_identifier_impls!(RadrootsEventPointer, hex_64('d').as_str()); + } + + #[test] + fn nostr_event_pointers_validate_relay_values() { + let event_id = RadrootsEventId::parse(hex_64('e')).expect("event id"); + let pointer = RadrootsNostrEventPointer::new( + event_id.clone(), + ["wss://relay.one.example", "wss://relay.two.example"], + ) + .expect("pointer"); + + assert_eq!(pointer.event_id, event_id); + assert_eq!( + pointer.relays, + vec![ + "wss://relay.one.example".to_owned(), + "wss://relay.two.example".to_owned() + ] + ); + + for relay in [ + "", + " wss://relay.example", + "wss://relay.example\n", + "wss://relay.example/\u{7}", + ] { + assert_eq!( + RadrootsNostrEventPointer::new( + RadrootsEventId::parse(hex_64('e')).expect("event id"), + [relay], + ) + .unwrap_err(), + RadrootsIdParseError::InvalidCharacter + ); + } + } + #[cfg(feature = "serde")] #[test] fn serde_deserialization_validates_identifiers() {