lib

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

commit e9d0d8af43950e156e6015663ad84c6a32601e30
parent 5c671d4b6206032be6a4c930930a7f72ef9e8edc
Author: triesap <tyson@radroots.org>
Date:   Tue,  7 Jul 2026 04:56:12 +0000

transport: harden transport coverage gates

- add mesh, mesh agent, transport, Reticulum, and publish protocol edge coverage
- cover Nostr outbox recovered-state, target filtering, and outcome classification paths
- separate Nostr outbox fallible result propagation for precise release-gate coverage
- validate focused transport tests, coverage gates, contract validation, and release preflight

Diffstat:
Mcrates/mesh/tests/mesh.rs | 126+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/mesh_agent_proto/tests/schema.rs | 110+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport/tests/transport.rs | 159+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport_nostr/src/outbox.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/transport_nostr/tests/transport.rs | 404+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport_publish_protocol/src/lib.rs | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/transport_reticulum/tests/reticulum_preview.rs | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 1226 insertions(+), 6 deletions(-)

diff --git a/crates/mesh/tests/mesh.rs b/crates/mesh/tests/mesh.rs @@ -55,9 +55,135 @@ fn payload_transmission_is_forbidden_in_mvp_frames() { #[test] fn custom_scope_has_explicit_namespace() { let scope = RadrootsMeshScope::custom(" Farm-North ").expect("custom scope"); + assert_eq!(scope.label(), "farm-north"); let frame = RadrootsMeshFrame::new(scope, Vec::new()); let encoded = encode_mesh_frame_cbor(&frame).expect("encode custom scope"); let decoded = decode_mesh_frame_cbor(&encoded).expect("decode custom scope"); assert_eq!(decoded.scope.cbor_label(), "custom:farm-north"); } + +#[test] +fn mesh_scope_and_payload_policy_parsers_reject_unknown_values() { + assert_eq!( + RadrootsMeshScope::custom(" ").expect_err("empty custom scope"), + RadrootsMeshError::EmptyCustomScope + ); + assert_eq!( + RadrootsMeshScope::parse("unscoped").expect_err("unknown scope"), + RadrootsMeshError::UnknownScope + ); + assert_eq!(RadrootsMeshScope::Local.label(), "local"); + assert_eq!(RadrootsMeshScope::Community.label(), "community"); + assert_eq!( + RadrootsMeshPayloadPolicy::parse("inline-payloads").expect_err("unknown policy"), + RadrootsMeshError::UnknownPayloadPolicy + ); + assert_eq!( + RadrootsMeshPayloadPolicy::PayloadTransmissionForbidden.label(), + "payload-forbidden" + ); +} + +#[test] +fn mesh_errors_have_stable_display_strings() { + let cases = [ + ( + RadrootsMeshError::EmptyCustomScope, + "mesh custom scope is empty", + ), + ( + RadrootsMeshError::PayloadTransmissionForbidden, + "mesh payload transmission is forbidden", + ), + (RadrootsMeshError::InvalidCbor, "mesh frame CBOR is invalid"), + ( + RadrootsMeshError::InvalidUtf8, + "mesh frame text is invalid UTF-8", + ), + (RadrootsMeshError::UnknownScope, "mesh scope is unknown"), + ( + RadrootsMeshError::UnknownPayloadPolicy, + "mesh payload policy is unknown", + ), + ( + RadrootsMeshError::UnsupportedVersion, + "mesh frame version is unsupported", + ), + ]; + + for (error, message) in cases { + assert_eq!(error.to_string(), message); + } +} + +#[test] +fn cbor_codec_covers_extended_integer_widths() { + let frame = RadrootsMeshFrame::new( + RadrootsMeshScope::Community, + vec![RadrootsMeshEventHead { + event_id: "event-with-wide-created-at".to_string(), + author: "author-with-wide-created-at".to_string(), + kind: 24, + created_at: u64::MAX, + }], + ); + let encoded = encode_mesh_frame_cbor(&frame).expect("encode wide frame"); + let decoded = decode_mesh_frame_cbor(&encoded).expect("decode wide frame"); + + assert_eq!(decoded, frame); +} + +#[test] +fn decoder_rejects_malformed_cbor_shapes() { + let encoded = encode_mesh_frame_cbor(&RadrootsMeshFrame::new( + RadrootsMeshScope::Local, + Vec::new(), + )) + .expect("encode default"); + let mut unsupported_version = encoded.clone(); + unsupported_version[2] = 2; + assert_eq!( + decode_mesh_frame_cbor(&unsupported_version).expect_err("unsupported version"), + RadrootsMeshError::UnsupportedVersion + ); + let mut frame = RadrootsMeshFrame::new(RadrootsMeshScope::Local, Vec::new()); + frame.version = 2; + assert_eq!( + frame.validate().expect_err("unsupported frame version"), + RadrootsMeshError::UnsupportedVersion + ); + + let cases = [ + vec![0x80], + vec![0xbc], + vec![0xa4], + vec![0xa5, 0x02], + vec![0xa5, 0x01, 0x01, 0x02, 0x61, 0xff], + vec![ + 0xa5, 0x01, 0x01, 0x02, 0x63, b'b', b'a', b'd', 0x03, 0x71, b'p', b'a', b'y', b'l', + b'o', b'a', b'd', b'-', b'f', b'o', b'r', b'b', b'i', b'd', b'd', b'e', b'n', 0x04, + 0x80, 0x05, 0xf6, + ], + vec![ + 0xa5, 0x01, 0x01, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x67, b'u', b'n', + b'k', b'n', b'o', b'w', b'n', 0x04, 0x80, 0x05, 0xf6, + ], + vec![ + 0xa5, 0x01, 0x01, 0x02, 0x65, b'l', b'o', b'c', b'a', b'l', 0x03, 0x71, b'p', b'a', + b'y', b'l', b'o', b'a', b'd', b'-', b'f', b'o', b'r', b'b', b'i', b'd', b'd', b'e', + b'n', 0x04, 0x80, 0x05, 0x00, + ], + ]; + + for malformed in cases { + assert!(decode_mesh_frame_cbor(&malformed).is_err()); + } + + let mut trailing = encoded; + trailing.push(0x00); + assert_eq!( + decode_mesh_frame_cbor(&trailing).expect_err("trailing byte"), + RadrootsMeshError::InvalidCbor + ); +} diff --git a/crates/mesh_agent_proto/tests/schema.rs b/crates/mesh_agent_proto/tests/schema.rs @@ -34,3 +34,113 @@ fn schema_validator_rejects_missing_required_surface() { RadrootsMeshAgentProtoError::MissingNamespace ); } + +#[test] +fn schema_validator_reports_each_missing_required_surface() { + let valid = RADROOTS_MESH_AGENT_SCHEMA; + let cases = [ + ( + valid.replace(RADROOTS_MESH_AGENT_SCHEMA_ID, "0x0000000000000000"), + RadrootsMeshAgentProtoError::MissingSchemaId, + ), + ( + valid.replace(RADROOTS_MESH_AGENT_SCHEMA_NAMESPACE, "radroots::missing"), + RadrootsMeshAgentProtoError::MissingNamespace, + ), + ( + valid.replace("struct MeshAgentRequest", "struct MissingMeshAgentRequest"), + RadrootsMeshAgentProtoError::MissingRequest, + ), + ( + valid.replace("frameCbor @2 :Data;", "frameBytes @2 :Data;"), + RadrootsMeshAgentProtoError::MissingRequest, + ), + ( + valid.replace("enum MeshAgentAction", "enum MissingMeshAgentAction"), + RadrootsMeshAgentProtoError::MissingAction, + ), + ( + valid.replace("validateFrame @0;", "validateSomethingElse @0;"), + RadrootsMeshAgentProtoError::MissingAction, + ), + ( + valid.replace("stageDelivery @1;", "stageSomethingElse @1;"), + RadrootsMeshAgentProtoError::MissingAction, + ), + ( + valid.replace("observeEventHead @2;", "observeSomethingElse @2;"), + RadrootsMeshAgentProtoError::MissingAction, + ), + ( + valid.replace( + "struct MeshAgentResponse", + "struct MissingMeshAgentResponse", + ), + RadrootsMeshAgentProtoError::MissingResponse, + ), + ( + valid.replace( + "enum MeshAgentResponseStatus", + "enum MissingMeshAgentResponseStatus", + ), + RadrootsMeshAgentProtoError::MissingResponse, + ), + ( + valid.replace("struct MeshAgentReceipt", "struct MissingMeshAgentReceipt"), + RadrootsMeshAgentProtoError::MissingReceipt, + ), + ( + valid.replace( + "acceptedEventHeads @1 :List(Text);", + "acceptedEventIds @1 :List(Text);", + ), + RadrootsMeshAgentProtoError::MissingReceipt, + ), + ( + valid.replace("struct MeshAgentError", "struct MissingMeshAgentError"), + RadrootsMeshAgentProtoError::MissingError, + ), + ]; + + for (schema, error) in cases { + assert_eq!(validate_schema_text(schema.as_str()), Err(error)); + } +} + +#[test] +fn mesh_agent_proto_errors_have_stable_display_strings() { + let cases = [ + ( + RadrootsMeshAgentProtoError::MissingSchemaId, + "mesh agent schema id is missing", + ), + ( + RadrootsMeshAgentProtoError::MissingNamespace, + "mesh agent schema namespace is missing", + ), + ( + RadrootsMeshAgentProtoError::MissingRequest, + "mesh agent request schema is missing", + ), + ( + RadrootsMeshAgentProtoError::MissingAction, + "mesh agent action schema is missing", + ), + ( + RadrootsMeshAgentProtoError::MissingResponse, + "mesh agent response schema is missing", + ), + ( + RadrootsMeshAgentProtoError::MissingReceipt, + "mesh agent receipt schema is missing", + ), + ( + RadrootsMeshAgentProtoError::MissingError, + "mesh agent error schema is missing", + ), + ]; + + for (error, message) in cases { + assert_eq!(error.to_string(), message); + } +} diff --git a/crates/transport/tests/transport.rs b/crates/transport/tests/transport.rs @@ -48,10 +48,18 @@ fn transport_kind_parser_round_trips_canonical_labels_and_custom_values() { RadrootsTransportKind::Local ); assert_eq!( + RadrootsTransportKind::Local.canonical_label(), + "local".to_owned() + ); + assert_eq!( RadrootsTransportKind::parse("fieldbus").expect("custom kind"), RadrootsTransportKind::Custom("fieldbus".to_owned()) ); assert_eq!( + RadrootsTransportKind::Custom("fieldbus".to_owned()).canonical_label(), + "fieldbus".to_owned() + ); + assert_eq!( RadrootsTransportKind::parse("bad kind").expect_err("invalid kind"), RadrootsTransportError::InvalidTransportKind ); @@ -144,3 +152,154 @@ fn fingerprint_parser_rejects_non_sha256_hex() { RadrootsTransportError::InvalidTargetUri ); } + +#[test] +fn transport_errors_have_stable_display_strings() { + let cases = [ + ( + RadrootsTransportError::EmptyTransportKind, + "transport kind is empty", + ), + ( + RadrootsTransportError::InvalidTransportKind, + "transport kind is invalid", + ), + ( + RadrootsTransportError::EmptyTargetUri, + "transport target URI is empty", + ), + ( + RadrootsTransportError::InvalidTargetUri, + "transport target URI is invalid", + ), + ( + RadrootsTransportError::EmptyTargetSet, + "transport target set is empty", + ), + ( + RadrootsTransportError::DuplicateTargetFingerprint, + "transport target set contains duplicate fingerprints", + ), + ( + RadrootsTransportError::InvalidTargetFingerprint, + "transport target fingerprint is invalid", + ), + ( + RadrootsTransportError::InvalidSatisfactionPolicy, + "transport satisfaction policy is invalid", + ), + ]; + + for (error, message) in cases { + assert_eq!(error.to_string(), message); + } +} + +#[test] +fn transport_kind_and_target_parsers_cover_negative_edges() { + assert_eq!( + RadrootsTransportKind::custom(" ").expect_err("empty kind"), + RadrootsTransportError::EmptyTransportKind + ); + for invalid in ["bad kind", "bad:kind", "bad/kind", "bad\nkind"] { + assert_eq!( + RadrootsTransportKind::custom(invalid).expect_err("invalid kind"), + RadrootsTransportError::InvalidTransportKind + ); + } + assert_eq!( + RadrootsTransportKind::custom(" FieldBus ").expect("custom kind"), + RadrootsTransportKind::Custom("fieldbus".to_owned()) + ); + + let no_scheme = + RadrootsTransportTargetUri::parse(" transport-target ").expect("schemeless target uri"); + assert_eq!(no_scheme.as_str(), "transport-target"); + assert_eq!(no_scheme.to_string(), "transport-target"); + let opaque = RadrootsTransportTargetUri::parse("RNS:PeerA").expect("opaque uri"); + assert_eq!(opaque.as_str(), "rns:PeerA"); + let authority = RadrootsTransportTargetUri::parse("MESH://Node.Example/path?q=1#frag") + .expect("authority uri"); + assert_eq!(authority.as_str(), "mesh://node.example/path?q=1#frag"); + + assert_eq!( + RadrootsTransportTargetUri::parse(" ").expect_err("empty uri"), + RadrootsTransportError::EmptyTargetUri + ); + for invalid in [ + "bad target", + ":target", + "1bad:target", + "bad_scheme://target", + "bad\target", + ] { + assert_eq!( + RadrootsTransportTargetUri::parse(invalid).expect_err("invalid uri"), + RadrootsTransportError::InvalidTargetUri + ); + } +} + +#[test] +fn target_fingerprints_and_sets_cover_accessors_and_validation() { + let target = RadrootsTransportTarget::new(RadrootsTransportKind::Mesh, "mesh://node.example") + .expect("mesh target"); + let parsed = + RadrootsTransportTargetFingerprint::parse(target.fingerprint.as_str().to_ascii_uppercase()) + .expect("uppercase fingerprint parses"); + assert_eq!(parsed.as_str(), target.fingerprint.as_str()); + assert_eq!(parsed.to_string(), target.fingerprint.as_str()); + assert_eq!( + RadrootsTransportTargetFingerprint::parse("g".repeat(64)).expect_err("non-hex fingerprint"), + RadrootsTransportError::InvalidTargetFingerprint + ); + + assert_eq!( + RadrootsTransportTargetSet::new(Vec::new()).expect_err("empty target set"), + RadrootsTransportError::EmptyTargetSet + ); + let target_set = RadrootsTransportTargetSet::new(vec![target]).expect("target set"); + assert_eq!(target_set.len(), 1); + assert!(!target_set.is_empty()); + assert_eq!(target_set.targets().len(), 1); +} + +#[test] +fn satisfaction_and_target_status_cover_all_contract_states() { + assert_eq!( + RadrootsTransportSatisfactionPolicy::AllTargets + .required_target_count(3) + .expect("all targets"), + 3 + ); + assert_eq!( + RadrootsTransportSatisfactionPolicy::AnyTarget + .required_target_count(3) + .expect("any target"), + 1 + ); + assert_eq!( + RadrootsTransportSatisfactionPolicy::AtLeast(4) + .required_target_count(3) + .expect_err("at least too high"), + RadrootsTransportError::InvalidSatisfactionPolicy + ); + + let statuses = [ + RadrootsTransportDeliveryTargetStatus::Pending, + RadrootsTransportDeliveryTargetStatus::Accepted, + RadrootsTransportDeliveryTargetStatus::Deferred, + RadrootsTransportDeliveryTargetStatus::Rejected, + RadrootsTransportDeliveryTargetStatus::Failed, + RadrootsTransportDeliveryTargetStatus::Unavailable, + ]; + assert!(!statuses[0].is_terminal()); + assert!(statuses[1..].iter().all(|status| status.is_terminal())); + assert!(RadrootsTransportDeliveryTargetStatus::Accepted.counts_as_satisfied()); + assert!( + statuses + .iter() + .filter(|status| **status != RadrootsTransportDeliveryTargetStatus::Accepted) + .all(|status| !status.counts_as_satisfied()) + ); +} diff --git a/crates/transport_nostr/src/outbox.rs b/crates/transport_nostr/src/outbox.rs @@ -107,11 +107,12 @@ where policy.relay_url_policy, )?; let target_strings = targets.relay_strings(); + let satisfaction_policy = satisfaction_policy_for_required_accept_count( + publishable.required_accept_count, + publishable.relays.len(), + )?; let request = RadrootsRelayPublishRequest::new(signed_event.clone(), targets, now_ms) - .with_satisfaction_policy(satisfaction_policy_for_required_accept_count( - publishable.required_accept_count, - publishable.relays.len(), - )?); + .with_satisfaction_policy(satisfaction_policy); let publish = match publish_signed_event(adapter, request).await { Ok(receipt) => receipt, Err(RadrootsRelayTransportError::Transport(message)) => adapter_transport_failure_receipt( @@ -315,12 +316,13 @@ async fn ingest_publish_observation( message: Option<&str>, observed_at_ms: i64, ) -> Result<(), RadrootsRelayTransportError> { - let mut observation = RadrootsTransportObservation::new( + let observation = RadrootsTransportObservation::new( RadrootsTransportKind::Nostr, relay_url, RadrootsTransportObservationType::NostrPublishAck, observed_at_ms, - )?; + ); + let mut observation = observation?; if let Some(message) = message { observation = observation.with_redacted_message(message); } @@ -342,3 +344,49 @@ fn event_from_signed(signed_event: &RadrootsSignedNostrEvent) -> RadrootsNostrEv sig: signed_event.sig.clone(), } } + +#[cfg(test)] +mod tests { + use super::{adapter_transport_failure_receipt, satisfaction_policy_for_required_accept_count}; + use radroots_transport::RadrootsTransportSatisfactionPolicy; + + #[test] + fn internal_outbox_publish_helpers_cover_policy_edges() { + assert_eq!( + satisfaction_policy_for_required_accept_count(2, 2).expect("all targets"), + RadrootsTransportSatisfactionPolicy::AllTargets + ); + assert_eq!( + satisfaction_policy_for_required_accept_count(1, 3).expect("at least one"), + RadrootsTransportSatisfactionPolicy::AtLeast(1) + ); + assert!( + satisfaction_policy_for_required_accept_count( + usize::from(u16::MAX) + 1, + usize::from(u16::MAX) + 2, + ) + .is_err() + ); + } + + #[test] + fn adapter_transport_failure_receipts_preserve_each_target() { + let receipt = adapter_transport_failure_receipt( + "event-1".to_owned(), + vec![ + "wss://relay-a.example".to_owned(), + "wss://relay-b.example".to_owned(), + ], + 2, + "offline".to_owned(), + ); + + assert_eq!(receipt.event_id, "event-1"); + assert_eq!(receipt.attempted_count, 2); + assert_eq!(receipt.retryable_count, 2); + assert_eq!(receipt.terminal_count, 0); + assert_eq!(receipt.quorum, 2); + assert!(!receipt.quorum_met); + assert!(receipt.relays.iter().all(|relay| relay.attempted)); + } +} diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -69,6 +69,36 @@ impl RadrootsRelayPublishAdapter for NostrJsonFailurePublishAdapter { } } +struct UnknownRelayReceiptPublishAdapter; + +impl RadrootsRelayPublishAdapter for UnknownRelayReceiptPublishAdapter { + fn publish<'a>( + &'a self, + request: RadrootsRelayPublishRequest, + ) -> BoxFuture<'a, Result<Vec<RadrootsRelayPublishRelayReceipt>, RadrootsRelayTransportError>> + { + Box::pin(async move { + let relay = request + .targets + .relays() + .first() + .expect("fixture target") + .as_str() + .to_owned(); + Ok(vec![ + RadrootsRelayPublishRelayReceipt::attempted( + relay, + RadrootsRelayOutcome::accepted(), + ), + RadrootsRelayPublishRelayReceipt::attempted( + RELAY_TERTIARY_WSS, + RadrootsRelayOutcome::accepted(), + ), + ]) + }) + } +} + fn fixture_keys() -> RadrootsNostrKeys { let secret_key = RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); @@ -471,6 +501,43 @@ fn outcome_prefix_classification_covers_required_kinds() { let outcome = RadrootsRelayOutcome::classify(message); assert_eq!(outcome.kind, kind); } + let labels = [ + (RadrootsRelayOutcomeKind::Accepted, "accepted"), + ( + RadrootsRelayOutcomeKind::DuplicateAccepted, + "duplicate_accepted", + ), + (RadrootsRelayOutcomeKind::Blocked, "blocked"), + (RadrootsRelayOutcomeKind::RateLimited, "rate_limited"), + (RadrootsRelayOutcomeKind::Invalid, "invalid"), + (RadrootsRelayOutcomeKind::PowRequired, "pow_required"), + (RadrootsRelayOutcomeKind::Restricted, "restricted"), + (RadrootsRelayOutcomeKind::AuthRequired, "auth_required"), + (RadrootsRelayOutcomeKind::Muted, "muted"), + (RadrootsRelayOutcomeKind::Unsupported, "unsupported"), + ( + RadrootsRelayOutcomeKind::PaymentRequired, + "payment_required", + ), + (RadrootsRelayOutcomeKind::Error, "error"), + (RadrootsRelayOutcomeKind::Timeout, "timeout"), + ( + RadrootsRelayOutcomeKind::ConnectionFailed, + "connection_failed", + ), + ( + RadrootsRelayOutcomeKind::RelayUrlRejected, + "relay_url_rejected", + ), + ( + RadrootsRelayOutcomeKind::SkippedAlreadyAccepted, + "skipped_already_accepted", + ), + (RadrootsRelayOutcomeKind::Unknown, "unknown"), + ]; + for (kind, label) in labels { + assert_eq!(kind.as_str(), label); + } assert!(RadrootsRelayOutcome::classify("duplicate: already have it").counts_toward_quorum()); assert!( @@ -486,6 +553,42 @@ fn outcome_prefix_classification_covers_required_kinds() { .status, radroots_transport::RadrootsTransportDeliveryTargetStatus::Accepted ); + assert_eq!( + RadrootsRelayOutcome::timeout("timeout: no OK") + .to_transport_outcome() + .status, + radroots_transport::RadrootsTransportDeliveryTargetStatus::Failed + ); + assert_eq!( + RadrootsRelayOutcome::classify("restricted: denied") + .to_transport_outcome() + .status, + radroots_transport::RadrootsTransportDeliveryTargetStatus::Rejected + ); + assert_eq!( + RadrootsRelayOutcome::connection_failed("offline") + .kind + .as_str(), + "connection_failed" + ); + assert_eq!( + RadrootsRelayOutcome::relay_url_rejected("unsafe") + .kind + .as_str(), + "relay_url_rejected" + ); +} + +#[test] +fn relay_transport_error_wraps_transport_contract_errors() { + let error = RadrootsRelayTransportError::from( + radroots_transport::RadrootsTransportError::EmptyTargetSet, + ); + + assert_eq!( + error.to_string(), + "Transport contract error: transport target set is empty" + ); } #[tokio::test] @@ -1146,6 +1249,31 @@ async fn fetch_subscription_mode_and_store_errors_are_reported() { } #[tokio::test] +async fn fetch_ingest_rejects_invalid_observation_endpoint() { + let signed = signed_post("invalid observation endpoint"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![RadrootsRelayFetchItem::Event { + relay_url: " ".to_owned(), + raw_json: signed.raw_json, + observed_at_ms: 1_300, + }]); + + let error = + fetch_and_ingest_relay_events(&adapter, &store, post_relay_fetch_request(1_300, 10)) + .await + .expect_err("invalid observation endpoint"); + + assert!(matches!( + error, + RadrootsRelayTransportError::EventStore( + radroots_event_store::RadrootsEventStoreError::Transport( + radroots_transport::RadrootsTransportError::EmptyTargetUri + ) + ) + )); +} + +#[tokio::test] async fn outbox_publish_persists_partial_success_and_skips_accepted_retry() { let signed = signed_post("hello"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); @@ -1469,6 +1597,142 @@ async fn outbox_publish_marks_published_without_adapter_when_all_relays_already_ } #[tokio::test] +async fn outbox_publish_ignores_unknown_adapter_receipts() { + let signed = signed_post("unknown receipt"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsFrozenEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at, + signed.tags.clone(), + signed.content.clone(), + signed.pubkey.as_str(), + ) + .expect("draft"); + let receipt = outbox + .enqueue_operation(all_targets_outbox_operation_input( + draft, + vec![RELAY_PRIMARY_WSS.to_owned()], + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "sign-a", 2_000, 1_000) + .await + .expect("claim") + .expect("claim"); + let signed = complete_claimed_signing(&outbox, &claimed, 1_100).await; + outbox.recover_expired_claims(2_001).await.expect("recover"); + let publish_claim = outbox + .claim_next_ready_event("publisher", "publish-a", 3_000, 2_100) + .await + .expect("claim") + .expect("publish claim"); + + let published = publish_claimed_outbox_event( + &outbox, + &store, + &UnknownRelayReceiptPublishAdapter, + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500), + 2_200, + ) + .await + .expect("publish"); + + assert_eq!(published.publish.attempted_count, 2); + assert!(published.publish.quorum_met); + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.state, RadrootsOutboxEventState::Published); + let observations = store + .observations_for_event(signed.id.as_str()) + .await + .expect("observations"); + assert_eq!(observations.len(), 1); + assert_eq!(observations[0].endpoint_uri.as_str(), RELAY_PRIMARY_WSS); +} + +#[tokio::test] +async fn outbox_publish_skips_non_nostr_targets() { + let signed = signed_post("mixed target"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsFrozenEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at, + signed.tags.clone(), + signed.content.clone(), + signed.pubkey.as_str(), + ) + .expect("draft"); + let receipt = outbox + .enqueue_operation(RadrootsOutboxOperationInput::new( + "publish_post", + draft, + RadrootsOutboxDeliveryPlanInput::new( + "transport.mixed.local", + 1, + RadrootsTransportSatisfactionPolicy::AllTargets, + vec![ + nostr_target(RELAY_PRIMARY_WSS), + RadrootsTransportTarget::new(RadrootsTransportKind::Reticulum, "reticulum:a") + .expect("reticulum target"), + ], + ), + 1_000, + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "sign-a", 2_000, 1_000) + .await + .expect("claim") + .expect("claim"); + complete_claimed_signing(&outbox, &claimed, 1_100).await; + outbox.recover_expired_claims(2_001).await.expect("recover"); + let publish_claim = outbox + .claim_next_ready_event("publisher", "publish-a", 3_000, 2_100) + .await + .expect("claim") + .expect("publish claim"); + let adapter = RadrootsMockRelayPublishAdapter::new(); + + let published = publish_claimed_outbox_event( + &outbox, + &store, + &adapter, + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500), + 2_200, + ) + .await + .expect("publish"); + + assert_eq!(published.publish.attempted_count, 1); + assert_eq!(adapter.captured_raw_events().len(), 1); + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.state, RadrootsOutboxEventState::Signed); + let targets = outbox + .delivery_targets(receipt.outbox_event_id) + .await + .expect("targets"); + assert!(targets.iter().any(|target| { + target.transport_kind == RadrootsTransportKind::Reticulum + && target.status == RadrootsOutboxDeliveryTargetStatus::DeferredUntilImplemented + })); +} + +#[tokio::test] async fn outbox_publish_marks_published_when_delivery_plan_satisfaction_is_met_with_failure_diagnostics() { let signed = signed_post("quorum"); @@ -1660,6 +1924,87 @@ async fn outbox_publish_republishes_accepted_relays_when_policy_requests_it() { } #[tokio::test] +async fn outbox_publish_republish_policy_keeps_terminal_targets_excluded() { + let signed = signed_post("republish terminal excluded"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsFrozenEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at, + signed.tags.clone(), + signed.content.clone(), + signed.pubkey.as_str(), + ) + .expect("draft"); + let receipt = outbox + .enqueue_operation(all_targets_outbox_operation_input( + draft, + vec![RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()], + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "sign-a", 2_000, 1_000) + .await + .expect("claim") + .expect("claim"); + complete_claimed_signing(&outbox, &claimed, 1_100).await; + outbox.recover_expired_claims(2_001).await.expect("recover"); + let publish_claim = outbox + .claim_next_ready_event("publisher", "publish-a", 3_000, 2_100) + .await + .expect("claim") + .expect("publish claim"); + let initial_targets = publish_claim.delivery_targets.clone(); + outbox + .mark_delivery_target_accepted( + publish_claim.outbox_event_id, + publish_claim.claim_token.as_str(), + initial_targets[0].delivery_target_id, + 2_150, + ) + .await + .expect("primary accepted"); + outbox + .mark_delivery_target_failed_terminal( + publish_claim.outbox_event_id, + publish_claim.claim_token.as_str(), + initial_targets[1].delivery_target_id, + "terminal", + 2_151, + ) + .await + .expect("secondary terminal"); + let adapter = RadrootsMockRelayPublishAdapter::new() + .with_outcome(RELAY_PRIMARY_WSS, RadrootsRelayOutcome::accepted()) + .with_outcome(RELAY_SECONDARY_WSS, RadrootsRelayOutcome::accepted()); + + let published = publish_claimed_outbox_event( + &outbox, + &store, + &adapter, + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500).republish_accepted_relays(true), + 2_200, + ) + .await + .expect("publish"); + + assert_eq!(published.publish.attempted_count, 1); + assert_eq!(published.publish.accepted_count, 1); + assert_eq!(published.publish.quorum, 1); + assert!(published.publish.quorum_met); + assert_eq!(adapter.captured_raw_events().len(), 1); + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.state, RadrootsOutboxEventState::FailedTerminal); +} + +#[tokio::test] async fn outbox_publish_requires_claimed_signed_event() { let signed = signed_post("missing signature"); let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); @@ -1765,6 +2110,65 @@ async fn outbox_publish_propagates_non_transport_adapter_errors_after_target_fil } #[tokio::test] +async fn outbox_publish_rejects_invalid_relay_target_uri_before_adapter_publish() { + let signed = signed_post("invalid relay target"); + let outbox = RadrootsOutbox::open_memory().await.expect("outbox"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let draft = RadrootsFrozenEventDraft::new( + "radroots.social.post.v1", + KIND_POST, + signed.created_at, + signed.tags, + signed.content, + signed.pubkey.as_str(), + ) + .expect("draft"); + let receipt = outbox + .enqueue_operation(all_targets_outbox_operation_input( + draft, + vec!["ws://127.0.0.1:9999".to_owned()], + )) + .await + .expect("enqueue"); + let claimed = outbox + .claim_next_ready_event("signer", "sign-a", 2_000, 1_000) + .await + .expect("claim") + .expect("claim"); + complete_claimed_signing(&outbox, &claimed, 1_100).await; + outbox.recover_expired_claims(2_001).await.expect("recover"); + let publish_claim = outbox + .claim_next_ready_event("publisher", "publish-a", 3_000, 2_100) + .await + .expect("claim") + .expect("publish claim"); + let adapter = RadrootsMockRelayPublishAdapter::new(); + + let error = publish_claimed_outbox_event( + &outbox, + &store, + &adapter, + &publish_claim, + RadrootsOutboxPublishPolicy::new(2_500), + 2_200, + ) + .await + .expect_err("invalid relay target"); + + assert!(matches!( + error, + RadrootsRelayTransportError::RelayUrlForbiddenDestination { .. } + )); + assert!(adapter.captured_raw_events().is_empty()); + let event = outbox + .get_event(receipt.outbox_event_id) + .await + .expect("event") + .expect("event"); + assert_eq!(event.state, RadrootsOutboxEventState::Publishing); +} + +#[tokio::test] async fn smoke_relay_fetch_processes_one_thousand_event_receipts() { let store = RadrootsEventStore::open_memory().await.expect("store"); let mut items = Vec::new(); diff --git a/crates/transport_publish_protocol/src/lib.rs b/crates/transport_publish_protocol/src/lib.rs @@ -722,4 +722,285 @@ mod tests { serde_json::from_str(encoded.as_str()).expect("decode"); assert_eq!(decoded, request); } + + #[test] + fn protocol_errors_have_stable_display_strings() { + let cases = [ + ( + TransportPublishProtocolError::InvalidHexField { + field: "id", + expected_len: 64, + }, + "id must be 64 lowercase hex characters", + ), + ( + TransportPublishProtocolError::InvalidKind(70_000), + "event kind 70000 exceeds transport publish range", + ), + ( + TransportPublishProtocolError::EmptyTag { index: 2 }, + "tag 2 must not be empty", + ), + ( + TransportPublishProtocolError::EmptyIdempotencyKey, + "idempotency key must not be empty", + ), + ( + TransportPublishProtocolError::EmptyTransportKind { index: 1 }, + "transport target 1 kind must not be empty", + ), + ( + TransportPublishProtocolError::EmptyEndpointUri { index: 3 }, + "transport target 3 endpoint_uri must not be empty", + ), + ( + TransportPublishProtocolError::TargetLimitExceeded { max: 1, actual: 2 }, + "transport target count 2 exceeds limit 1", + ), + ( + TransportPublishProtocolError::EmptyTargetSet, + "transport publish target set must not be empty", + ), + ( + TransportPublishProtocolError::InvalidQuorum, + "delivery quorum must be greater than zero", + ), + ( + TransportPublishProtocolError::EmptyPrincipalId, + "principal id must not be empty", + ), + ( + TransportPublishProtocolError::EmptyJobId, + "job id must not be empty", + ), + ]; + + for (error, message) in cases { + assert_eq!(error.to_string(), message); + } + } + + #[test] + fn signed_event_validation_rejects_each_invalid_event_shape() { + let mut invalid_id = event(); + invalid_id.id = "A".repeat(64); + assert!(matches!( + invalid_id.validate(), + Err(TransportPublishProtocolError::InvalidHexField { field: "id", .. }) + )); + + let mut invalid_pubkey = event(); + invalid_pubkey.pubkey = "g".repeat(64); + assert!(matches!( + invalid_pubkey.validate(), + Err(TransportPublishProtocolError::InvalidHexField { + field: "pubkey", + .. + }) + )); + + let mut invalid_sig = event(); + invalid_sig.sig = "2".repeat(127); + assert!(matches!( + invalid_sig.validate(), + Err(TransportPublishProtocolError::InvalidHexField { field: "sig", .. }) + )); + + let mut invalid_kind = event(); + invalid_kind.kind = u16::MAX as u32 + 1; + assert_eq!( + invalid_kind.validate(), + Err(TransportPublishProtocolError::InvalidKind( + u16::MAX as u32 + 1 + )) + ); + + let mut empty_tag = event(); + empty_tag.tags.push(Vec::new()); + assert_eq!( + empty_tag.validate(), + Err(TransportPublishProtocolError::EmptyTag { index: 1 }) + ); + } + + #[test] + fn target_and_delivery_policy_validation_cover_all_modes() { + assert_eq!( + TransportPublishPreviewBehavior::default(), + TransportPublishPreviewBehavior::RejectDeliveryAttempts + ); + let explicit = TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example"), + TransportPublishTarget::reticulum_preview( + "reticulum:preview", + TransportPublishPreviewBehavior::DeferDeliveryPlans, + ), + ]); + let nostr = TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::RequestThenAuthorWriteThenDaemonDefault, + vec!["wss://relay.example".to_owned()], + ); + assert_eq!(explicit.request_target_count(), 2); + assert_eq!(nostr.request_target_count(), 1); + + let mut empty_targets = TransportPublishEventRequest { + event: event(), + target_policy: TransportPublishTargetPolicy::explicit_targets(Vec::new()), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: None, + timeout_ms: None, + }; + assert_eq!( + empty_targets.validate(10), + Err(TransportPublishProtocolError::EmptyTargetSet) + ); + + empty_targets.target_policy = + TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget { + transport_kind: " ".to_owned(), + endpoint_uri: "wss://relay.example".to_owned(), + preview_behavior: None, + }]); + assert_eq!( + empty_targets.validate(10), + Err(TransportPublishProtocolError::EmptyTransportKind { index: 0 }) + ); + + empty_targets.target_policy = TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::ExplicitOnly, + vec![" ".to_owned()], + ); + assert_eq!( + empty_targets.validate(10), + Err(TransportPublishProtocolError::EmptyEndpointUri { index: 0 }) + ); + + empty_targets.delivery_policy = TransportPublishDeliveryPolicy::Quorum { quorum: 0 }; + empty_targets.target_policy = nostr; + assert_eq!( + empty_targets.validate(10), + Err(TransportPublishProtocolError::InvalidQuorum) + ); + + assert_eq!( + TransportPublishDeliveryPolicy::Any.required_target_count(0), + 0 + ); + assert_eq!( + TransportPublishDeliveryPolicy::Any.required_target_count(3), + 1 + ); + assert_eq!( + TransportPublishDeliveryPolicy::All.required_target_count(3), + 3 + ); + assert_eq!( + TransportPublishDeliveryPolicy::Quorum { quorum: 2 }.required_target_count(3), + 2 + ); + } + + #[test] + fn outcome_kinds_cover_negative_classification_edges() { + let satisfied = [ + TransportPublishOutcomeKind::Accepted, + TransportPublishOutcomeKind::DuplicateAccepted, + TransportPublishOutcomeKind::SkippedAlreadyAccepted, + ]; + let retryable = [ + TransportPublishOutcomeKind::RateLimited, + TransportPublishOutcomeKind::PowRequired, + TransportPublishOutcomeKind::AuthRequired, + TransportPublishOutcomeKind::Error, + TransportPublishOutcomeKind::Timeout, + TransportPublishOutcomeKind::ConnectionFailed, + TransportPublishOutcomeKind::Unknown, + ]; + let terminal = [ + TransportPublishOutcomeKind::Blocked, + TransportPublishOutcomeKind::Invalid, + TransportPublishOutcomeKind::Restricted, + TransportPublishOutcomeKind::Muted, + TransportPublishOutcomeKind::Unsupported, + TransportPublishOutcomeKind::PaymentRequired, + TransportPublishOutcomeKind::TargetRejected, + TransportPublishOutcomeKind::Deferred, + TransportPublishOutcomeKind::Unavailable, + ]; + + for kind in satisfied { + assert!(kind.counts_toward_satisfaction()); + assert!(!kind.is_retryable()); + assert!(!kind.is_terminal_failure()); + } + for kind in retryable { + assert!(!kind.counts_toward_satisfaction()); + assert!(kind.is_retryable()); + assert!(!kind.is_terminal_failure()); + } + for kind in terminal { + assert!(!kind.counts_toward_satisfaction()); + assert!(!kind.is_retryable()); + assert!(kind.is_terminal_failure()); + } + } + + #[test] + fn job_validation_rejects_empty_job_invalid_kind_and_invalid_delivery_policy() { + let base = TransportPublishJobView { + job_id: "job-1".to_owned(), + status: TransportPublishJobStatus::Accepted, + terminal: false, + delivery_satisfied: false, + event_id: "0".repeat(64), + pubkey: "1".repeat(64), + event_kind: 30_402, + target_policy: TransportPublishTargetPolicy::nostr( + NostrPublishTargetSourcePolicy::DaemonDefaultOnly, + Vec::new(), + ), + delivery_policy: TransportPublishDeliveryPolicy::Any, + target_count: 0, + acknowledged_count: 0, + retryable_count: 0, + terminal_count: 0, + requested_at_ms: 1, + completed_at_ms: None, + last_error: None, + targets: Vec::new(), + }; + + let mut empty_job = base.clone(); + empty_job.job_id = " ".to_owned(); + assert_eq!( + empty_job.validate(), + Err(TransportPublishProtocolError::EmptyJobId) + ); + + let mut invalid_kind = base.clone(); + invalid_kind.event_kind = u16::MAX as u32 + 1; + assert_eq!( + invalid_kind.validate(), + Err(TransportPublishProtocolError::InvalidKind( + u16::MAX as u32 + 1 + )) + ); + + let mut invalid_pubkey = base.clone(); + invalid_pubkey.pubkey = "x".repeat(64); + assert!(matches!( + invalid_pubkey.validate(), + Err(TransportPublishProtocolError::InvalidHexField { + field: "pubkey", + .. + }) + )); + + let mut invalid_delivery = base; + invalid_delivery.delivery_policy = TransportPublishDeliveryPolicy::Quorum { quorum: 0 }; + assert_eq!( + invalid_delivery.validate(), + Err(TransportPublishProtocolError::InvalidQuorum) + ); + } } diff --git a/crates/transport_reticulum/tests/reticulum_preview.rs b/crates/transport_reticulum/tests/reticulum_preview.rs @@ -56,8 +56,16 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() { assert_eq!(endpoint.as_str(), "reticulum:preview-target"); assert_eq!(endpoint.to_string(), "reticulum:preview-target"); assert_eq!(endpoint.clone().into_string(), "reticulum:preview-target"); + assert_eq!( + RadrootsReticulumPreviewEndpoint::default().as_str(), + "reticulum:preview-unavailable" + ); assert_eq!( + RadrootsReticulumPreviewEndpoint::parse(" ").expect_err("empty endpoint"), + RadrootsReticulumPreviewError::InvalidEndpoint + ); + assert_eq!( RadrootsReticulumPreviewEndpoint::parse("reticulum:").expect_err("empty endpoint"), RadrootsReticulumPreviewError::InvalidEndpoint ); @@ -71,6 +79,11 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() { RadrootsReticulumPreviewError::InvalidEndpoint ); assert_eq!( + RadrootsReticulumPreviewEndpoint::parse("reticulum:bad\ntarget") + .expect_err("control endpoint"), + RadrootsReticulumPreviewError::InvalidEndpoint + ); + assert_eq!( RadrootsReticulumPreviewProfile::new( "transport reticulum", endpoint, @@ -79,6 +92,27 @@ fn endpoint_and_profile_validation_are_strict_and_canonical() { .expect_err("profile id whitespace"), RadrootsReticulumPreviewError::InvalidProfileId ); + assert_eq!( + RadrootsReticulumPreviewProfile::new( + "", + RadrootsReticulumPreviewEndpoint::default(), + RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts, + ) + .expect_err("empty profile id"), + RadrootsReticulumPreviewError::InvalidProfileId + ); + let profile = RadrootsReticulumPreviewProfile::new( + "transport.reticulum.custom", + RadrootsReticulumPreviewEndpoint::parse("reticulum:custom").expect("custom endpoint"), + RadrootsReticulumPreviewBehavior::DeferDeliveryPlans, + ) + .expect("custom profile"); + assert_eq!(profile.profile_id(), "transport.reticulum.custom"); + assert_eq!(profile.endpoint().as_str(), "reticulum:custom"); + assert_eq!( + profile.behavior(), + RadrootsReticulumPreviewBehavior::DeferDeliveryPlans + ); } #[test] @@ -144,6 +178,14 @@ fn non_reticulum_targets_are_rejected_without_nostr_routing() { #[test] fn fetch_reports_preview_unavailable_without_observed_events() { let transport = RadrootsReticulumPreviewTransport::default(); + assert_eq!( + transport.profile().profile_id(), + "transport.reticulum.preview" + ); + assert_eq!( + transport.status().implementation_state, + RadrootsTransportImplementationState::PreviewUnavailable + ); let receipt = transport .fetch(RadrootsReticulumPreviewFetchRequest::new("fetch-1", 10).expect("fetch request")) .expect("fetch receipt"); @@ -163,6 +205,26 @@ fn fetch_reports_preview_unavailable_without_observed_events() { RadrootsReticulumPreviewFetchRequest::new("fetch-0", 0).expect_err("zero limit"), RadrootsReticulumPreviewError::InvalidFetchLimit ); + assert_eq!( + transport + .fetch(RadrootsReticulumPreviewFetchRequest { + request_id: "fetch-public-zero".to_owned(), + max_events: 0, + }) + .expect_err("zero limit at transport boundary"), + RadrootsReticulumPreviewError::InvalidFetchLimit + ); + let deferred_transport = RadrootsReticulumPreviewTransport::new( + RadrootsReticulumPreviewProfile::default() + .with_behavior(RadrootsReticulumPreviewBehavior::DeferDeliveryPlans), + ); + let deferred = deferred_transport + .fetch(RadrootsReticulumPreviewFetchRequest::new("fetch-deferred", 1).expect("fetch")) + .expect("fetch receipt"); + assert_eq!( + deferred.outcome.status, + RadrootsTransportDeliveryTargetStatus::Deferred + ); } #[test] @@ -175,3 +237,33 @@ fn public_models_round_trip_through_serde() { assert_eq!(decoded, profile); } + +#[test] +fn reticulum_preview_errors_and_defaults_are_stable() { + assert_eq!( + RadrootsReticulumPreviewBehavior::default(), + RadrootsReticulumPreviewBehavior::RejectDeliveryAttempts + ); + let cases = [ + ( + RadrootsReticulumPreviewError::InvalidEndpoint, + "invalid Reticulum preview endpoint", + ), + ( + RadrootsReticulumPreviewError::InvalidProfileId, + "invalid Reticulum preview profile id", + ), + ( + RadrootsReticulumPreviewError::InvalidFetchLimit, + "Reticulum preview fetch limit must be greater than zero", + ), + ( + RadrootsReticulumPreviewError::NonReticulumTarget, + "Reticulum preview transport received a non-Reticulum target", + ), + ]; + + for (error, message) in cases { + assert_eq!(error.to_string(), message); + } +}