commit 59e7456f4f3d354f578fadeb642fce7c25862be9
parent 2a2d79afd5a9766e5e954f55c5b31d8f391dbfa9
Author: triesap <tyson@radroots.org>
Date: Fri, 26 Jun 2026 11:37:15 +0000
tests: harden SDK coverage gates
- add SDK runtime coverage for GeoNames, private store, market projection, and storage edges
- cover configured trade, listing, farm, signer, DVM, and sync failure paths
- exercise radrootsd proxy adapter outcomes and outbox retry/terminal transitions
- keep generated package surfaces reproducible while satisfying coverage policy
Diffstat:
15 files changed, 3175 insertions(+), 69 deletions(-)
diff --git a/crates/sdk/src/market_runtime.rs b/crates/sdk/src/market_runtime.rs
@@ -158,3 +158,7 @@ impl MarketListingSearchRow {
})
}
}
+
+#[cfg(test)]
+#[path = "../tests/unit/market_runtime_tests.rs"]
+mod tests;
diff --git a/crates/sdk/src/private_store.rs b/crates/sdk/src/private_store.rs
@@ -306,3 +306,7 @@ fn private_store_error(error: impl ToString) -> RadrootsSdkError {
message: error.to_string(),
}
}
+
+#[cfg(test)]
+#[path = "../tests/unit/private_store_tests.rs"]
+mod tests;
diff --git a/crates/sdk/tests/geonames.rs b/crates/sdk/tests/geonames.rs
@@ -1,11 +1,44 @@
#![cfg(feature = "runtime")]
use radroots_sdk::{
- GEONAMES_ASSET_HOST, GEONAMES_ASSET_VERSION, GeoNamesAssetState, RadrootsClient,
- RadrootsGeoNamesConfig, RadrootsSdkError, RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind,
- RadrootsSdkRecoveryAction,
+ GEONAMES_ASSET_HOST, GEONAMES_ASSET_VERSION, GeoNamesAssetFetcher, GeoNamesAssetSpec,
+ GeoNamesAssetState, GeocoderError, RadrootsClient, RadrootsGeoNamesConfig, RadrootsSdkError,
+ RadrootsSdkErrorClass, RadrootsSdkGeoNamesErrorKind, RadrootsSdkRecoveryAction,
};
+const TEST_SPEC: GeoNamesAssetSpec = GeoNamesAssetSpec {
+ version: "test",
+ file_name: "geonames-test.db",
+ url: "https://assets.radroots.io/data/geonames/geonames-test.db",
+ allowed_host: "assets.radroots.io",
+ byte_size: 4,
+ sha256: "53bc5cce8c5764019bb4ce6e597ec3885b71608668c9b6ef4940d364d7a914fa",
+};
+
+const BAD_HOST_SPEC: GeoNamesAssetSpec = GeoNamesAssetSpec {
+ version: "bad-host",
+ file_name: "geonames-bad-host.db",
+ url: "https://example.com/data/geonames/geonames-bad-host.db",
+ allowed_host: "assets.radroots.io",
+ byte_size: 4,
+ sha256: "53bc5cce8c5764019bb4ce6e597ec3885b71608668c9b6ef4940d364d7a914fa",
+};
+
+struct BytesFetcher(Vec<u8>);
+
+impl GeoNamesAssetFetcher for BytesFetcher {
+ fn fetch(&self, _url: &str) -> Result<Vec<u8>, GeocoderError> {
+ Ok(self.0.clone())
+ }
+}
+
+fn geonames_error<T>(result: Result<T, RadrootsSdkError>) -> RadrootsSdkError {
+ match result {
+ Ok(_) => panic!("expected GeoNames error"),
+ Err(error) => error,
+ }
+}
+
#[tokio::test]
async fn sdk_geonames_client_resolves_shared_cache_paths_and_reports_missing_state() {
let tempdir = tempfile::tempdir().expect("tempdir");
@@ -59,11 +92,36 @@ async fn sdk_geonames_client_resolves_shared_cache_paths_and_reports_missing_sta
#[tokio::test]
async fn sdk_geonames_client_reports_missing_config_as_structured_error() {
let sdk = RadrootsClient::builder().build().await.expect("sdk");
- let error = sdk
- .geonames()
+ let geonames = sdk.geonames();
+ assert!(geonames.config().is_none());
+ for error in [
+ geonames.root_path().expect_err("missing root path config"),
+ geonames
+ .database_path()
+ .expect_err("missing geonames config"),
+ geonames
+ .database_path_for_version("1.1")
+ .expect_err("missing version path config"),
+ geonames.inspect().expect_err("missing inspect config"),
+ geonames.ensure().expect_err("missing ensure config"),
+ geonames
+ .ensure_with_fetcher(&BytesFetcher(b"bad!".to_vec()))
+ .expect_err("missing ensure fetcher config"),
+ geonames
+ .ensure_with_spec_and_fetcher(&TEST_SPEC, &BytesFetcher(b"bad!".to_vec()))
+ .expect_err("missing ensure spec config"),
+ ] {
+ assert_missing_config_error(error);
+ }
+
+ let error = geonames
.database_path()
.expect_err("missing geonames config");
+ assert_missing_config_error(error);
+}
+
+fn assert_missing_config_error(error: RadrootsSdkError) {
match &error {
RadrootsSdkError::GeoNames { kind, .. } => {
assert_eq!(*kind, RadrootsSdkGeoNamesErrorKind::Configuration);
@@ -79,3 +137,93 @@ async fn sdk_geonames_client_reports_missing_config_as_structured_error() {
);
assert_eq!(error.detail_json()["detail"]["kind"], "configuration");
}
+
+#[tokio::test]
+async fn sdk_geonames_client_inspects_and_ensures_versioned_assets_without_network() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let cache_root = tempdir.path().join("cache");
+ let sdk = RadrootsClient::builder()
+ .geonames_config(RadrootsGeoNamesConfig::new(cache_root.clone()))
+ .build()
+ .await
+ .expect("sdk");
+ let geonames = sdk.geonames();
+ let custom_path = cache_root.join("custom").join(TEST_SPEC.file_name);
+
+ let missing = geonames
+ .inspect_path_with_spec(&custom_path, &TEST_SPEC)
+ .expect("missing inspect");
+ assert_eq!(missing.state, GeoNamesAssetState::Missing);
+ assert_eq!(missing.version, "test");
+ assert_eq!(missing.path, custom_path);
+
+ let default_ensure = geonames
+ .ensure_with_fetcher(&BytesFetcher(b"bad!".to_vec()))
+ .expect_err("default ensure invalid sqlite");
+ assert!(matches!(
+ default_ensure,
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Integrity,
+ ..
+ }
+ ));
+
+ let ensure_error = geonames
+ .ensure_with_spec_and_fetcher(&TEST_SPEC, &BytesFetcher(b"bad!".to_vec()))
+ .expect_err("ensure invalid sqlite");
+ assert!(matches!(
+ ensure_error,
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Integrity,
+ ..
+ }
+ ));
+
+ std::fs::create_dir_all(custom_path.parent().expect("custom parent")).expect("custom parent");
+ std::fs::write(&custom_path, b"bad!").expect("invalid sqlite");
+ let invalid = geonames
+ .inspect_path_with_spec(&custom_path, &TEST_SPEC)
+ .expect("invalid inspect");
+ assert_eq!(invalid.state, GeoNamesAssetState::Invalid);
+ assert_eq!(invalid.byte_size, Some(4));
+ assert_eq!(
+ invalid.sha256.as_deref(),
+ Some("53bc5cce8c5764019bb4ce6e597ec3885b71608668c9b6ef4940d364d7a914fa")
+ );
+ assert!(
+ invalid
+ .validation_error
+ .expect("validation error")
+ .contains("SQLite")
+ );
+
+ let open_error =
+ geonames_error(geonames.open_verified_path_with_spec(&custom_path, &TEST_SPEC));
+ assert!(matches!(
+ open_error,
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Integrity,
+ ..
+ }
+ ));
+
+ let default_open_error = geonames_error(geonames.open_verified());
+ assert!(matches!(
+ default_open_error,
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Cache,
+ ..
+ }
+ ));
+
+ let config_error = geonames
+ .ensure_with_spec_and_fetcher(&BAD_HOST_SPEC, &BytesFetcher(b"bad!".to_vec()))
+ .expect_err("bad host");
+ assert!(matches!(
+ config_error,
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Configuration,
+ ..
+ }
+ ));
+}
diff --git a/crates/sdk/tests/orders_runtime.rs b/crates/sdk/tests/orders_runtime.rs
@@ -3370,6 +3370,66 @@ fn order_status_issue_mapping_preserves_kind_codes_and_event_ids() {
"cancellation_previous_mismatch"
),
multi_issue!(ForkedLifecycle, ForkedLifecycle, "forked_lifecycle"),
+ single_issue!(
+ ValidationReceiptWithoutPendingAgreement,
+ ValidationReceiptWithoutPendingAgreement,
+ "validation_receipt_without_pending_agreement"
+ ),
+ single_issue!(
+ ValidationReceiptOrderIdMismatch,
+ ValidationReceiptOrderIdMismatch,
+ "validation_receipt_order_id_mismatch"
+ ),
+ single_issue!(
+ ValidationReceiptTypeMismatch,
+ ValidationReceiptTypeMismatch,
+ "validation_receipt_type_mismatch"
+ ),
+ single_issue!(
+ ValidationReceiptRootMismatch,
+ ValidationReceiptRootMismatch,
+ "validation_receipt_root_mismatch"
+ ),
+ single_issue!(
+ ValidationReceiptTargetMismatch,
+ ValidationReceiptTargetMismatch,
+ "validation_receipt_target_mismatch"
+ ),
+ single_issue!(
+ ValidationReceiptListingMismatch,
+ ValidationReceiptListingMismatch,
+ "validation_receipt_listing_mismatch"
+ ),
+ multi_issue!(
+ ConflictingValidationReceipts,
+ ConflictingValidationReceipts,
+ "conflicting_validation_receipts"
+ ),
+ {
+ let event_id = deterministic_event_id("deterministic_validation_failure");
+ (
+ RadrootsOrderIssue::DeterministicValidationFailure {
+ event_id: event_id.clone(),
+ reason: "fixture validation failed".to_owned(),
+ },
+ SdkOrderStatusIssueKind::DeterministicValidationFailure,
+ "deterministic_validation_failure",
+ vec![event_id],
+ )
+ },
+ {
+ let expected_event_id = deterministic_event_id("stale_listing_event_expected");
+ let current_event_id = deterministic_event_id("stale_listing_event_current");
+ (
+ RadrootsOrderIssue::StaleListingEvent {
+ expected_event_id: expected_event_id.clone(),
+ current_event_id: current_event_id.clone(),
+ },
+ SdkOrderStatusIssueKind::StaleListingEvent,
+ "stale_listing_event",
+ vec![expected_event_id, current_event_id],
+ )
+ },
];
for (issue, expected_kind, expected_code, expected_event_ids) in cases {
diff --git a/crates/sdk/tests/sync_runtime.rs b/crates/sdk/tests/sync_runtime.rs
@@ -73,6 +73,7 @@ struct RecordedProxyRequest {
enum ProxyResponseMode {
Accepted,
Retryable,
+ Terminal,
}
#[derive(Clone)]
@@ -165,37 +166,61 @@ fn write_proxy_response(
) {
let body_json: serde_json::Value = serde_json::from_str(body).expect("body json");
let event = &body_json["params"]["event"];
- let (status, terminal, delivery_satisfied, acknowledged_count, retryable_count, relay) =
- match mode {
- ProxyResponseMode::Accepted => (
- "delivery_satisfied",
- true,
- true,
- 1,
- 0,
- serde_json::json!({
- "relay_url": "wss://daemon-resolved.example.com",
- "source": "daemon_default",
- "attempted": true,
- "outcome_kind": "accepted",
- "message": "accepted"
- }),
- ),
- ProxyResponseMode::Retryable => (
- "delivery_unsatisfied_retryable",
- false,
- false,
- 0,
- 1,
- serde_json::json!({
- "relay_url": "wss://daemon-resolved.example.com",
- "source": "daemon_default",
- "attempted": false,
- "outcome_kind": "connection_failed",
- "message": "dns lookup failed"
- }),
- ),
- };
+ let (
+ status,
+ terminal,
+ delivery_satisfied,
+ acknowledged_count,
+ retryable_count,
+ terminal_count,
+ relay,
+ ) = match mode {
+ ProxyResponseMode::Accepted => (
+ "delivery_satisfied",
+ true,
+ true,
+ 1,
+ 0,
+ 0,
+ serde_json::json!({
+ "relay_url": "wss://daemon-resolved.example.com",
+ "source": "daemon_default",
+ "attempted": true,
+ "outcome_kind": "accepted",
+ "message": "accepted"
+ }),
+ ),
+ ProxyResponseMode::Retryable => (
+ "delivery_unsatisfied_retryable",
+ false,
+ false,
+ 0,
+ 1,
+ 0,
+ serde_json::json!({
+ "relay_url": "wss://daemon-resolved.example.com",
+ "source": "daemon_default",
+ "attempted": false,
+ "outcome_kind": "connection_failed",
+ "message": "dns lookup failed"
+ }),
+ ),
+ ProxyResponseMode::Terminal => (
+ "delivery_unsatisfied_terminal",
+ true,
+ false,
+ 0,
+ 0,
+ 1,
+ serde_json::json!({
+ "relay_url": "wss://daemon-resolved.example.com",
+ "source": "daemon_default",
+ "attempted": true,
+ "outcome_kind": "invalid",
+ "message": "event rejected"
+ }),
+ ),
+ };
let response_body = serde_json::json!({
"jsonrpc": "2.0",
"id": body_json["id"],
@@ -214,7 +239,7 @@ fn write_proxy_response(
"relay_count": 1,
"acknowledged_count": acknowledged_count,
"retryable_count": retryable_count,
- "terminal_count": 0,
+ "terminal_count": terminal_count,
"requested_at_ms": 1700000000000i64,
"completed_at_ms": 1700000000100i64,
"relays": [relay]
@@ -1563,6 +1588,113 @@ async fn product_push_outbox_radrootsd_proxy_idempotency_is_attempt_scoped() {
);
}
+#[cfg(feature = "radrootsd-proxy")]
+#[tokio::test]
+async fn product_push_outbox_radrootsd_proxy_error_and_terminal_paths_update_outbox() {
+ let closed_listener = TcpListener::bind("127.0.0.1:0").expect("bind closed proxy");
+ let closed_endpoint = format!("http://{}/rpc", closed_listener.local_addr().expect("addr"));
+ drop(closed_listener);
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let retryable_sdk = RadrootsClient::builder()
+ .directory_storage(tempdir.path().join("retryable-sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .publish_transport(SdkPublishTransport::RadrootsdProxy(
+ RadrootsdProxyConfig::new(closed_endpoint).with_timeout(Duration::from_millis(50)),
+ ))
+ .build()
+ .await
+ .expect("retryable sdk");
+ retryable_sdk
+ .listings()
+ .enqueue_publish_with_explicit_signer(
+ ListingEnqueuePublishRequest::new(
+ actor(),
+ listing(LISTING_A_D_TAG, "Proxy Error Coffee"),
+ SdkRelayTargetPolicy::use_publish_transport(),
+ ),
+ &FixtureSigner::new(SELLER),
+ )
+ .await
+ .expect("enqueue retryable");
+
+ let retryable = retryable_sdk
+ .sync()
+ .push_outbox(
+ PushOutboxRequest::new()
+ .with_limit(1)
+ .with_next_attempt_delay_ms(1),
+ )
+ .await
+ .expect("retryable proxy push");
+ assert_eq!(retryable.retryable_events, 1);
+ assert_eq!(
+ retryable.events[0].final_state,
+ PushOutboxEventState::PublishRetryable
+ );
+ assert!(retryable.events[0].relays.is_empty());
+ let retryable_status = retryable_sdk
+ .sync()
+ .status(SyncStatusRequest::new())
+ .await
+ .expect("retryable status");
+ assert_eq!(retryable_status.outbox.retryable_events, 1);
+ assert!(
+ retryable_status
+ .outbox
+ .last_error
+ .as_deref()
+ .is_some_and(|error| error.contains("radrootsd proxy publish failed"))
+ );
+
+ let (terminal_endpoint, terminal_handle) =
+ spawn_publish_proxy_sequence_server(vec![ProxyResponseMode::Terminal]);
+ let terminal_sdk = RadrootsClient::builder()
+ .directory_storage(tempdir.path().join("terminal-sdk"))
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .publish_transport(SdkPublishTransport::RadrootsdProxy(
+ RadrootsdProxyConfig::new(terminal_endpoint),
+ ))
+ .build()
+ .await
+ .expect("terminal sdk");
+ let enqueue = terminal_sdk
+ .listings()
+ .enqueue_publish_with_explicit_signer(
+ ListingEnqueuePublishRequest::new(
+ actor(),
+ listing(LISTING_B_D_TAG, "Terminal Coffee"),
+ SdkRelayTargetPolicy::use_publish_transport(),
+ ),
+ &FixtureSigner::new(SELLER),
+ )
+ .await
+ .expect("enqueue terminal");
+
+ let terminal = terminal_sdk
+ .sync()
+ .push_outbox(PushOutboxRequest::new().with_limit(1))
+ .await
+ .expect("terminal proxy push");
+ assert_eq!(terminal.terminal_events, 1);
+ assert_eq!(terminal.events[0].outbox_event_id, enqueue.outbox_event_id);
+ assert_eq!(
+ terminal.events[0].final_state,
+ PushOutboxEventState::FailedTerminal
+ );
+ assert_eq!(
+ terminal.events[0].relays[0].outcome_kind,
+ PushOutboxRelayOutcomeKind::Invalid
+ );
+ let terminal_status = terminal_sdk
+ .sync()
+ .status(SyncStatusRequest::new())
+ .await
+ .expect("terminal status");
+ assert_eq!(terminal_status.outbox.failed_terminal_events, 1);
+ assert_eq!(terminal_status.outbox.ready_signed_events, 0);
+ assert_eq!(terminal_handle.join().expect("terminal requests").len(), 1);
+}
+
#[test]
fn push_outbox_contract_dtos_serialize_deterministically() {
let request = PushOutboxRequest::new()
diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs
@@ -3,6 +3,9 @@ use radroots_publish_proxy_protocol::{
PublishJobStatus, PublishJobView, PublishRelayOutcome, PublishRelayOutcomeKind,
PublishRelaySource,
};
+use radroots_relay_transport::{
+ RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayUrlPolicy,
+};
use std::io::{Read, Write};
use std::net::TcpListener;
use std::thread::JoinHandle;
@@ -168,6 +171,7 @@ fn assert_message(error: RadrootsdError, fragment: &str) {
fn auth_headers_omit_or_redact_bearer_authorization() {
let none = auth_headers(&RadrootsdAuth::None).expect("none auth");
assert!(!none.contains_key(AUTHORIZATION));
+ assert_eq!(format!("{:?}", RadrootsdAuth::None), "None");
let bearer = auth_headers(&RadrootsdAuth::BearerToken("sdk-token".into())).expect("bearer");
assert_eq!(
@@ -185,6 +189,46 @@ fn auth_headers_omit_or_redact_bearer_authorization() {
format!("{:?}", RadrootsdAuth::BearerToken("token-secret".into())),
"BearerToken(<redacted>)"
);
+ assert!(
+ RadrootsdError::InvalidAuthHeader("bad header".to_owned())
+ .to_string()
+ .contains("invalid radrootsd bearer token header")
+ );
+ assert_eq!(
+ RadrootsdError::InvalidRequest("invalid request".to_owned()).to_string(),
+ "invalid request"
+ );
+ assert_eq!(
+ RadrootsdError::Http("http failed".to_owned()).to_string(),
+ "http failed"
+ );
+ assert_eq!(
+ RadrootsdError::MalformedResponse("bad envelope".to_owned()).to_string(),
+ "bad envelope"
+ );
+}
+
+#[test]
+fn proxy_config_builders_preserve_typed_runtime_options() {
+ let config = RadrootsdProxyConfig::new("http://127.0.0.1:8080/rpc")
+ .with_auth(RadrootsdAuth::BearerToken("sdk-token".to_owned()))
+ .with_relay_policy(PublishRelayPolicy::ExplicitOnly)
+ .with_timeout(Duration::from_millis(250))
+ .with_request_timeout_ms(1_500);
+ let adapter = RadrootsdProxyPublishAdapter::new(config.clone());
+
+ assert_eq!(adapter.config(), &config);
+ assert_eq!(adapter.config().endpoint, "http://127.0.0.1:8080/rpc");
+ assert_eq!(
+ adapter.config().auth,
+ RadrootsdAuth::BearerToken("sdk-token".to_owned())
+ );
+ assert_eq!(
+ adapter.config().relay_policy,
+ PublishRelayPolicy::ExplicitOnly
+ );
+ assert_eq!(adapter.config().timeout, Duration::from_millis(250));
+ assert_eq!(adapter.config().request_timeout_ms, Some(1_500));
}
#[test]
@@ -229,7 +273,11 @@ fn decode_jsonrpc_response_validates_envelope_and_errors() {
assert_message(error, "principal unauthorized");
assert!(matches!(
- decode_jsonrpc_response::<PublishEventResponse>(
+ decode_jsonrpc_response::<serde_json::Value>(METHOD_EVENT, "expected", "not json"),
+ Err(RadrootsdError::MalformedResponse(_))
+ ));
+ assert!(matches!(
+ decode_jsonrpc_response::<serde_json::Value>(
METHOD_EVENT,
"expected",
r#"{"jsonrpc":"2.0","id":"other","result":{}}"#
@@ -237,13 +285,29 @@ fn decode_jsonrpc_response_validates_envelope_and_errors() {
Err(RadrootsdError::MalformedResponse(_))
));
assert!(matches!(
- decode_jsonrpc_response::<PublishEventResponse>(
+ decode_jsonrpc_response::<serde_json::Value>(
METHOD_EVENT,
"expected",
r#"{"jsonrpc":"2.0","id":"expected"}"#
),
Err(RadrootsdError::MalformedResponse(_))
));
+ assert!(matches!(
+ decode_jsonrpc_response::<serde_json::Value>(
+ METHOD_EVENT,
+ "expected",
+ r#"{"jsonrpc":"1.0","id":"expected","result":{}}"#
+ ),
+ Err(RadrootsdError::MalformedResponse(_))
+ ));
+ assert!(matches!(
+ decode_jsonrpc_response::<serde_json::Value>(
+ METHOD_EVENT,
+ "expected",
+ r#"{"jsonrpc":"2.0","id":"expected","result":{},"error":{"code":-32002,"message":"both"}}"#
+ ),
+ Err(RadrootsdError::MalformedResponse(_))
+ ));
}
#[test]
@@ -269,6 +333,77 @@ fn daemon_outcomes_map_to_relay_transport_receipts() {
RadrootsRelayOutcomeKind::SkippedAlreadyAccepted
);
assert!(skipped.quorum_met);
+
+ let cases = [
+ (
+ PublishRelayOutcomeKind::Accepted,
+ RadrootsRelayOutcomeKind::Accepted,
+ ),
+ (
+ PublishRelayOutcomeKind::DuplicateAccepted,
+ RadrootsRelayOutcomeKind::DuplicateAccepted,
+ ),
+ (
+ PublishRelayOutcomeKind::Blocked,
+ RadrootsRelayOutcomeKind::Blocked,
+ ),
+ (
+ PublishRelayOutcomeKind::RateLimited,
+ RadrootsRelayOutcomeKind::RateLimited,
+ ),
+ (
+ PublishRelayOutcomeKind::Invalid,
+ RadrootsRelayOutcomeKind::Invalid,
+ ),
+ (
+ PublishRelayOutcomeKind::PowRequired,
+ RadrootsRelayOutcomeKind::PowRequired,
+ ),
+ (
+ PublishRelayOutcomeKind::Restricted,
+ RadrootsRelayOutcomeKind::Restricted,
+ ),
+ (
+ PublishRelayOutcomeKind::AuthRequired,
+ RadrootsRelayOutcomeKind::AuthRequired,
+ ),
+ (
+ PublishRelayOutcomeKind::Muted,
+ RadrootsRelayOutcomeKind::Muted,
+ ),
+ (
+ PublishRelayOutcomeKind::Unsupported,
+ RadrootsRelayOutcomeKind::Unsupported,
+ ),
+ (
+ PublishRelayOutcomeKind::Error,
+ RadrootsRelayOutcomeKind::Error,
+ ),
+ (
+ PublishRelayOutcomeKind::Timeout,
+ RadrootsRelayOutcomeKind::Timeout,
+ ),
+ (
+ PublishRelayOutcomeKind::ConnectionFailed,
+ RadrootsRelayOutcomeKind::ConnectionFailed,
+ ),
+ (
+ PublishRelayOutcomeKind::RelayUrlRejected,
+ RadrootsRelayOutcomeKind::RelayUrlRejected,
+ ),
+ (
+ PublishRelayOutcomeKind::Unknown,
+ RadrootsRelayOutcomeKind::Unknown,
+ ),
+ ];
+ for (proxy_kind, relay_kind) in cases {
+ let receipt = proxy_receipt_from_response(PublishEventResponse {
+ deduplicated: false,
+ job: job(proxy_kind),
+ })
+ .expect("receipt");
+ assert_eq!(receipt.relays[0].outcome.kind, relay_kind);
+ }
}
#[tokio::test]
@@ -322,6 +457,162 @@ async fn publish_event_http_errors_omit_body_and_token_material() {
}
#[tokio::test]
+async fn publish_event_empty_http_error_reports_empty_body() {
+ let (endpoint, _handle) = spawn_http_server("500 Internal Server Error", "");
+
+ let error = publish_event(
+ endpoint.as_str(),
+ &RadrootsdAuth::None,
+ &publish_request(),
+ Duration::from_secs(2),
+ )
+ .await
+ .expect_err("http error");
+
+ assert!(error.to_string().contains("response body empty"));
+}
+
+#[tokio::test]
+async fn relay_publish_adapter_derives_delivery_policy_and_timeout() {
+ for (target_count, quorum, expected_policy) in [
+ (2, 2, PublishDeliveryPolicy::All),
+ (2, 1, PublishDeliveryPolicy::Any),
+ (3, 2, PublishDeliveryPolicy::Quorum { quorum: 2 }),
+ ] {
+ let response_body = publish_response_json();
+ let (endpoint, handle) = spawn_http_server("200 OK", response_body.as_str());
+ let adapter = RadrootsdProxyPublishAdapter::new(
+ RadrootsdProxyConfig::new(endpoint).with_request_timeout_ms(4_000),
+ );
+ let relays = (0..target_count)
+ .map(|index| format!("wss://relay-{index}.example.com"))
+ .collect::<Vec<_>>();
+ let targets =
+ RadrootsRelayTargetSet::new(&relays, RadrootsRelayUrlPolicy::Public).expect("targets");
+
+ let receipts = adapter
+ .publish(
+ RadrootsRelayPublishRequest::new(signed_event(), targets, 10)
+ .with_accepted_quorum(quorum),
+ )
+ .await
+ .expect("adapter publish");
+
+ assert_eq!(receipts[0].outcome.kind, RadrootsRelayOutcomeKind::Accepted);
+ let recorded = handle.join().expect("server thread");
+ let body: serde_json::Value =
+ serde_json::from_str(recorded.body.as_str()).expect("request body");
+ assert_eq!(body["params"]["timeout_ms"], 4_000);
+ assert_eq!(
+ serde_json::from_value::<PublishDeliveryPolicy>(
+ body["params"]["delivery_policy"].clone()
+ )
+ .expect("delivery policy"),
+ expected_policy
+ );
+ }
+}
+
+#[tokio::test]
+async fn relay_publish_adapter_maps_proxy_errors_to_transport_errors() {
+ let adapter = RadrootsdProxyPublishAdapter::new(
+ RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc").with_timeout(Duration::from_millis(50)),
+ );
+ let targets = RadrootsRelayTargetSet::new(
+ &["wss://relay.example.com".to_owned()],
+ RadrootsRelayUrlPolicy::Public,
+ )
+ .expect("targets");
+
+ let error = adapter
+ .publish(RadrootsRelayPublishRequest::new(
+ signed_event(),
+ targets,
+ 1_700_000_000_000,
+ ))
+ .await
+ .expect_err("transport error");
+
+ assert!(matches!(
+ error,
+ radroots_relay_transport::RadrootsRelayTransportError::Transport(message)
+ if message.contains("radrootsd")
+ ));
+}
+
+#[tokio::test]
+async fn publish_signed_event_rejects_invalid_protocol_requests_before_http() {
+ let adapter =
+ RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc"));
+ let base = RadrootsdProxyPublishRequest {
+ signed_event: signed_event(),
+ relays: vec!["wss://relay.example.com".to_owned()],
+ delivery_policy: PublishDeliveryPolicy::Any,
+ idempotency_key: Some("idem-1".to_owned()),
+ timeout_ms: Some(1_000),
+ };
+
+ let mut invalid_event_kind = base.clone();
+ invalid_event_kind.signed_event.kind = 70_000;
+ let mut empty_event_tag = base.clone();
+ empty_event_tag.signed_event.tags = vec![Vec::new()];
+ let mut invalid_quorum = base.clone();
+ invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 };
+ let mut too_many_relays = base.clone();
+ too_many_relays.relays = (0..=SDK_RADROOTSD_PROXY_MAX_RELAYS)
+ .map(|index| format!("wss://relay-{index}.example.com"))
+ .collect();
+ let mut empty_relay = base.clone();
+ empty_relay.relays = vec![" ".to_owned()];
+ let mut empty_idempotency = base;
+ empty_idempotency.idempotency_key = Some(" ".to_owned());
+
+ for request in [
+ invalid_event_kind,
+ empty_event_tag,
+ invalid_quorum,
+ too_many_relays,
+ empty_relay,
+ empty_idempotency,
+ ] {
+ assert!(matches!(
+ adapter.publish_signed_event(request).await,
+ Err(RadrootsdError::InvalidRequest(_))
+ ));
+ }
+}
+
+#[test]
+fn proxy_receipt_from_response_rejects_invalid_daemon_job_contracts() {
+ let mut empty_job_id = job(PublishRelayOutcomeKind::Accepted);
+ empty_job_id.job_id = " ".to_owned();
+ let mut invalid_event_id = job(PublishRelayOutcomeKind::Accepted);
+ invalid_event_id.event_id = "not-an-event-id".to_owned();
+ let mut invalid_pubkey = job(PublishRelayOutcomeKind::Accepted);
+ invalid_pubkey.pubkey = "not-a-pubkey".to_owned();
+ let mut invalid_kind = job(PublishRelayOutcomeKind::Accepted);
+ invalid_kind.event_kind = 70_000;
+ let mut invalid_quorum = job(PublishRelayOutcomeKind::Accepted);
+ invalid_quorum.delivery_policy = PublishDeliveryPolicy::Quorum { quorum: 0 };
+
+ for job in [
+ empty_job_id,
+ invalid_event_id,
+ invalid_pubkey,
+ invalid_kind,
+ invalid_quorum,
+ ] {
+ assert!(matches!(
+ proxy_receipt_from_response(PublishEventResponse {
+ deduplicated: false,
+ job,
+ }),
+ Err(RadrootsdError::InvalidRequest(_))
+ ));
+ }
+}
+
+#[tokio::test]
async fn adapter_rejects_invalid_request_before_transport() {
let adapter =
RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc"));
diff --git a/crates/sdk/tests/unit/error_tests.rs b/crates/sdk/tests/unit/error_tests.rs
@@ -1,9 +1,10 @@
use super::{
- RadrootsSdkError, RadrootsSdkPartialLocalMutationError, RadrootsSdkPartialLocalMutationFailure,
- RadrootsSdkRecoveryAction, redacted_relay_url,
+ RadrootsSdkError, RadrootsSdkGeoNamesErrorKind, RadrootsSdkPartialLocalMutationError,
+ RadrootsSdkPartialLocalMutationFailure, RadrootsSdkRecoveryAction, redacted_relay_url,
};
use radroots_authority::RadrootsAuthorityError;
use radroots_events::contract::RadrootsActorRole;
+use radroots_geocoder::GeocoderError;
#[test]
fn partial_local_mutation_constructor_preserves_supplied_error() {
@@ -137,6 +138,24 @@ fn outbox_error_conversion_handles_empty_targets_and_fallbacks() {
RadrootsSdkError::from(radroots_outbox::RadrootsOutboxError::EventNotFound(42)),
RadrootsSdkError::Outbox { ref message } if message.contains("42")
));
+ assert!(matches!(
+ RadrootsSdkError::from(radroots_outbox::RadrootsOutboxError::IdempotencyConflict {
+ operation_kind: "listing.publish.v1".to_owned(),
+ expected_pubkey: "a".repeat(64),
+ idempotency_key: "idem-1".to_owned(),
+ existing_digest: "b".repeat(64),
+ new_digest: "c".repeat(64),
+ }),
+ RadrootsSdkError::IdempotencyConflict {
+ ref operation_kind,
+ ref expected_pubkey_prefix,
+ ref existing_digest_prefix,
+ ref new_digest_prefix,
+ } if operation_kind == "listing.publish.v1"
+ && expected_pubkey_prefix == "aaaaaaaaaaaa"
+ && existing_digest_prefix == "bbbbbbbbbbbb"
+ && new_digest_prefix == "cccccccccccc"
+ ));
}
#[test]
@@ -174,6 +193,46 @@ fn relay_transport_error_conversion_redacts_and_classifies_url_errors() {
));
assert!(matches!(
RadrootsSdkError::from(
+ radroots_relay_transport::RadrootsRelayTransportError::RelayUrlUserinfo {
+ url: "wss://user:secret@relay.example.com".to_owned(),
+ },
+ ),
+ RadrootsSdkError::InvalidRelayUrl { ref url, ref reason }
+ if url == "wss://<redacted>@relay.example.com"
+ && reason == "relay URL must not include userinfo"
+ ));
+ assert!(matches!(
+ RadrootsSdkError::from(
+ radroots_relay_transport::RadrootsRelayTransportError::WsRequiresLocalhostPolicy {
+ url: "ws://relay.example.com".to_owned(),
+ },
+ ),
+ RadrootsSdkError::InvalidRelayUrl { ref reason, .. }
+ if reason == "ws relay URL requires localhost policy"
+ ));
+ assert!(matches!(
+ RadrootsSdkError::from(
+ radroots_relay_transport::RadrootsRelayTransportError::RelayUrlForbiddenDestination {
+ url: "ws://127.0.0.1:9000".to_owned(),
+ reason: "localhost disabled".to_owned(),
+ },
+ ),
+ RadrootsSdkError::InvalidRelayUrl { ref reason, .. }
+ if reason == "localhost disabled"
+ ));
+ assert!(matches!(
+ RadrootsSdkError::from(
+ radroots_relay_transport::RadrootsRelayTransportError::RelayUrlResolvedForbiddenDestination {
+ url: "ws://relay.example.com".to_owned(),
+ address: "127.0.0.1".to_owned(),
+ reason: "loopback disabled".to_owned(),
+ },
+ ),
+ RadrootsSdkError::InvalidRelayUrl { ref reason, .. }
+ if reason == "relay URL resolved to forbidden address `127.0.0.1`: loopback disabled"
+ ));
+ assert!(matches!(
+ RadrootsSdkError::from(
radroots_relay_transport::RadrootsRelayTransportError::EmptyTargetSet
),
RadrootsSdkError::EmptyTargetRelays { ref operation } if operation == "relay publish"
@@ -250,6 +309,37 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() {
expected_pubkey_prefix: "aaaaaaaaaaaa".to_owned(),
signer_pubkey_prefix: "bbbbbbbbbbbb".to_owned(),
},
+ RadrootsSdkError::SignerUnavailable {
+ mode: "configured".to_owned(),
+ reason: "missing".to_owned(),
+ },
+ RadrootsSdkError::SignerRequestRejected {
+ mode: "myc_nip46".to_owned(),
+ reason: "denied".to_owned(),
+ },
+ RadrootsSdkError::SignerRequestTimedOut {
+ mode: "myc_nip46".to_owned(),
+ },
+ RadrootsSdkError::SignerAuthChallengePending {
+ mode: "myc_nip46".to_owned(),
+ auth_url: Some("https://auth.example.com/challenge".to_owned()),
+ },
+ RadrootsSdkError::SignerAuthChallengePending {
+ mode: "myc_nip46".to_owned(),
+ auth_url: None,
+ },
+ RadrootsSdkError::SignerTransport {
+ mode: "myc_nip46".to_owned(),
+ reason: "offline".to_owned(),
+ },
+ RadrootsSdkError::SignerProtocol {
+ mode: "myc_nip46".to_owned(),
+ reason: "bad envelope".to_owned(),
+ },
+ RadrootsSdkError::SignerReturnedEventDrift {
+ operation: "listing.publish".to_owned(),
+ reason: "id changed".to_owned(),
+ },
RadrootsSdkError::EmptyTargetRelays {
operation: "relay publish".to_owned(),
},
@@ -291,6 +381,33 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() {
RadrootsSdkError::Outbox {
message: "outbox".to_owned(),
},
+ RadrootsSdkError::PrivateStore {
+ message: "private".to_owned(),
+ },
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Configuration,
+ message: "missing cache root".to_owned(),
+ },
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Download,
+ message: "download".to_owned(),
+ },
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Cache,
+ message: "cache".to_owned(),
+ },
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Integrity,
+ message: "integrity".to_owned(),
+ },
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Schema,
+ message: "schema".to_owned(),
+ },
+ RadrootsSdkError::GeoNames {
+ kind: RadrootsSdkGeoNamesErrorKind::Lookup,
+ message: "lookup".to_owned(),
+ },
RadrootsSdkError::RelayTransport {
message: "transport".to_owned(),
},
@@ -319,3 +436,84 @@ fn sdk_error_contract_methods_cover_representative_classes_and_details() {
assert!(error.to_string().starts_with("sdk "));
}
}
+
+#[test]
+fn geonames_error_conversion_maps_source_errors_to_sdk_kinds() {
+ let path = std::path::PathBuf::from("geonames-test.db");
+ let cases = vec![
+ (
+ GeocoderError::InvalidAssetUrl {
+ url: "http://assets.radroots.io/geonames.db".to_owned(),
+ },
+ RadrootsSdkGeoNamesErrorKind::Configuration,
+ ),
+ (
+ GeocoderError::InvalidAssetHost {
+ url: "https://example.com/geonames.db".to_owned(),
+ expected_host: "assets.radroots.io".to_owned(),
+ actual_host: "example.com".to_owned(),
+ },
+ RadrootsSdkGeoNamesErrorKind::Configuration,
+ ),
+ (
+ GeocoderError::InvalidAssetLength {
+ path: path.clone(),
+ expected: 4,
+ actual: 3,
+ },
+ RadrootsSdkGeoNamesErrorKind::Integrity,
+ ),
+ (
+ GeocoderError::InvalidAssetSha256 {
+ path: path.clone(),
+ expected: "a".repeat(64),
+ actual: "b".repeat(64),
+ },
+ RadrootsSdkGeoNamesErrorKind::Integrity,
+ ),
+ (
+ GeocoderError::InvalidAssetSqlite {
+ path: path.clone(),
+ detail: "file is not a database".to_owned(),
+ },
+ RadrootsSdkGeoNamesErrorKind::Integrity,
+ ),
+ (
+ GeocoderError::InvalidAssetIntegrity {
+ path: path.clone(),
+ result: "row mismatch".to_owned(),
+ },
+ RadrootsSdkGeoNamesErrorKind::Integrity,
+ ),
+ (
+ GeocoderError::InvalidAssetSchema {
+ path: path.clone(),
+ detail: "missing table".to_owned(),
+ },
+ RadrootsSdkGeoNamesErrorKind::Schema,
+ ),
+ (
+ GeocoderError::AssetLockUnavailable { path: path.clone() },
+ RadrootsSdkGeoNamesErrorKind::Cache,
+ ),
+ (
+ GeocoderError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "missing")),
+ RadrootsSdkGeoNamesErrorKind::Cache,
+ ),
+ (
+ GeocoderError::CountryCenterNotFound {
+ country_id: "XX".to_owned(),
+ },
+ RadrootsSdkGeoNamesErrorKind::Lookup,
+ ),
+ ];
+
+ for (source, expected_kind) in cases {
+ let error = RadrootsSdkError::from(source);
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::GeoNames { kind, .. } if kind == expected_kind
+ ));
+ }
+}
diff --git a/crates/sdk/tests/unit/farms_runtime_tests.rs b/crates/sdk/tests/unit/farms_runtime_tests.rs
@@ -1,6 +1,7 @@
use super::*;
use crate::{RadrootsSdkLocalKeySigner, RadrootsSdkSignerProvider};
use radroots_nostr::prelude::RadrootsNostrKeys;
+use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
#[path = "../support/fixture_signer.rs"]
mod fixture_signer;
@@ -34,6 +35,89 @@ fn farm(d_tag: &str, name: &str) -> RadrootsFarm {
}
}
+async fn fixture_geocoder(tempdir: &tempfile::TempDir, feature_name: Option<&str>) -> Geocoder {
+ let path = tempdir.path().join(match feature_name {
+ Some(name) if name.trim().is_empty() => "geonames-blank.db",
+ Some(_) => "geonames-fixture.db",
+ None => "geonames-empty.db",
+ });
+ let options = SqliteConnectOptions::new()
+ .filename(&path)
+ .create_if_missing(true);
+ let pool = SqlitePoolOptions::new()
+ .max_connections(1)
+ .connect_with(options)
+ .await
+ .expect("geonames fixture pool");
+ sqlx::raw_sql(
+ r#"
+ CREATE TABLE countries(
+ id TEXT,
+ name TEXT,
+ PRIMARY KEY (id)
+ );
+ CREATE TABLE admin1(
+ country_id TEXT,
+ id INTEGER,
+ name TEXT,
+ PRIMARY KEY (country_id, id)
+ );
+ CREATE TABLE features(
+ id INTEGER,
+ name TEXT,
+ country_id TEXT,
+ admin1_id INTEGER,
+ PRIMARY KEY (id)
+ );
+ CREATE TABLE coordinates(
+ feature_id INTEGER,
+ latitude REAL,
+ longitude REAL,
+ PRIMARY KEY (feature_id)
+ );
+ CREATE INDEX coordinates_lat_lng ON coordinates (latitude, longitude);
+ CREATE VIEW geonames AS
+ SELECT
+ features.id,
+ features.name,
+ admin1.id AS admin1_id,
+ admin1.name AS admin1_name,
+ countries.id AS country_id,
+ countries.name AS country_name,
+ coordinates.latitude AS latitude,
+ coordinates.longitude AS longitude
+ FROM features
+ LEFT JOIN countries ON features.country_id = countries.id
+ LEFT JOIN admin1 ON features.country_id = admin1.country_id AND features.admin1_id = admin1.id
+ JOIN coordinates ON features.id = coordinates.feature_id;
+ "#,
+ )
+ .execute(&pool)
+ .await
+ .expect("schema geonames fixture");
+ if let Some(name) = feature_name {
+ sqlx::raw_sql(
+ r#"
+ INSERT INTO countries (id, name) VALUES ('FX', 'Fixture Country');
+ INSERT INTO admin1 (country_id, id, name) VALUES ('FX', 1, 'Fixture Region');
+ INSERT INTO coordinates (feature_id, latitude, longitude) VALUES (1, 12.25, -34.50);
+ "#,
+ )
+ .execute(&pool)
+ .await
+ .expect("seed geonames fixture");
+ sqlx::query(
+ "INSERT INTO features (id, name, country_id, admin1_id) VALUES (1, ?1, 'FX', 1)",
+ )
+ .bind(name)
+ .execute(&pool)
+ .await
+ .expect("seed geonames feature");
+ }
+ pool.close().await;
+ Geocoder::open_path(path).expect("open geonames fixture")
+}
+
#[test]
fn farm_publish_plan_rejects_invalid_draft_tags() {
let actor = RadrootsActorContext::test(
@@ -159,6 +243,28 @@ fn farm_public_locality_derivation_covers_country_fallback_and_empty_names() {
assert_eq!(locality.country.as_deref(), Some("FX"));
assert_eq!(locality.geohash5, "e4pmw");
+ let named_region = GeocoderReverseResult {
+ admin1_name: Some(" Fixture Region ".to_owned()),
+ country_name: Some(" Fixture Country ".to_owned()),
+ ..reverse.clone()
+ };
+ let named_locality =
+ public_locality_from_reverse(SdkExactLocation::new(12.26, -34.51), &named_region)
+ .expect("named locality");
+ assert_eq!(named_locality.region.as_deref(), Some("Fixture Region"));
+ assert_eq!(named_locality.country.as_deref(), Some("Fixture Country"));
+
+ let blank_optional_names = GeocoderReverseResult {
+ admin1_name: Some(" ".to_owned()),
+ country_name: Some(" ".to_owned()),
+ ..reverse.clone()
+ };
+ let fallback_locality =
+ public_locality_from_reverse(SdkExactLocation::new(12.26, -34.51), &blank_optional_names)
+ .expect("fallback locality");
+ assert_eq!(fallback_locality.region, None);
+ assert_eq!(fallback_locality.country.as_deref(), Some("FX"));
+
let blank_name = GeocoderReverseResult {
name: " ".to_owned(),
..reverse
@@ -170,6 +276,34 @@ fn farm_public_locality_derivation_covers_country_fallback_and_empty_names() {
..
})
));
+
+ for location in [
+ SdkExactLocation::new(f64::NAN, -34.51),
+ SdkExactLocation::new(12.26, f64::INFINITY),
+ SdkExactLocation::new(-90.1, -34.51),
+ SdkExactLocation::new(90.1, -34.51),
+ SdkExactLocation::new(12.26, -180.1),
+ SdkExactLocation::new(12.26, 180.1),
+ ] {
+ assert!(matches!(
+ validate_exact_location(location),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+ assert!(matches!(
+ geohash5(location),
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+ }
+ assert!(matches!(
+ sdk_timestamp_ms(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)),
+ Err(RadrootsSdkError::TimestampOutOfRange { .. })
+ ));
+ assert!(matches!(
+ sdk_timestamp_ms(RadrootsSdkTimestamp::from_unix_seconds(
+ (i64::MAX as u64 / 1_000) + 1
+ )),
+ Err(RadrootsSdkError::TimestampOutOfRange { .. })
+ ));
}
#[tokio::test]
@@ -330,3 +464,217 @@ async fn farm_configured_local_signer_enqueues_publish_without_explicit_signer()
assert_eq!(receipt.signed_event_id, receipt.expected_event_id);
assert_eq!(receipt.state, SdkMutationState::StoredAndQueued);
}
+
+#[tokio::test]
+async fn farm_configured_enqueue_reports_prepare_and_signer_errors() {
+ let keys = RadrootsNostrKeys::generate();
+ let farmer = keys.public_key().to_hex();
+ let configured_sdk = crate::RadrootsClient::builder()
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500))
+ .signer_provider(RadrootsSdkSignerProvider::LocalKey(
+ RadrootsSdkLocalKeySigner::new(keys).expect("signer"),
+ ))
+ .build()
+ .await
+ .expect("configured sdk");
+ let actor =
+ RadrootsActorContext::test(farmer.as_str(), [RadrootsActorRole::Farmer]).expect("actor");
+
+ assert!(matches!(
+ configured_sdk
+ .farms()
+ .enqueue_publish(FarmEnqueuePublishRequest::new(
+ actor.clone(),
+ farm("AAAAAAAAAAAAAAAAAAAAA!", "Invalid Configured Farm"),
+ SdkRelayTargetPolicy::try_explicit([RELAY_A], SdkRelayUrlPolicy::Public)
+ .expect("target relays"),
+ ))
+ .await,
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let no_signer_sdk = crate::RadrootsClient::builder()
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500))
+ .build()
+ .await
+ .expect("no signer sdk");
+ let plan = no_signer_sdk
+ .farms()
+ .prepare_publish(FarmPreparePublishRequest::new(
+ actor.clone(),
+ farm(FARM_A_D_TAG, "Missing Configured Signer Farm"),
+ ))
+ .expect("plan");
+ assert!(matches!(
+ no_signer_sdk
+ .farms()
+ .enqueue_prepared_publish(
+ &actor,
+ plan,
+ SdkRelayTargetPolicy::try_explicit([RELAY_A], SdkRelayUrlPolicy::Public)
+ .expect("target relays"),
+ None,
+ )
+ .await,
+ Err(RadrootsSdkError::SignerUnavailable { .. })
+ ));
+}
+
+#[tokio::test]
+async fn farm_private_location_default_client_and_lookup_report_store_edges() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let geocoder = fixture_geocoder(&tempdir, Some("Fixture Town")).await;
+ let empty_geocoder = fixture_geocoder(&tempdir, None).await;
+ let blank_geocoder = fixture_geocoder(&tempdir, Some(" ")).await;
+ let sdk = crate::RadrootsClient::builder()
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500))
+ .build()
+ .await
+ .expect("sdk");
+ let actor = farmer_actor();
+ let request = FarmPrivateLocationUpsertRequest::new(
+ actor.clone(),
+ FARM_A_D_TAG,
+ SdkExactLocation::new(12.26, -34.51),
+ );
+
+ assert!(matches!(
+ sdk.farms().upsert_private_location(request).await,
+ Err(RadrootsSdkError::GeoNames {
+ kind: crate::RadrootsSdkGeoNamesErrorKind::Configuration,
+ ..
+ })
+ ));
+
+ let farm_addr = farm_addr(&actor, FARM_A_D_TAG).expect("farm addr");
+ assert_eq!(
+ sdk.farms()
+ .private_location(&farm_addr)
+ .await
+ .expect("missing location"),
+ None
+ );
+
+ let stored = sdk
+ .farms()
+ .upsert_private_location_with_geocoder(
+ FarmPrivateLocationUpsertRequest::new(
+ actor.clone(),
+ FARM_A_D_TAG,
+ SdkExactLocation::new(12.26, -34.51),
+ )
+ .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_501)),
+ &geocoder,
+ )
+ .await
+ .expect("stored location");
+ assert_eq!(stored.farm_addr, farm_addr);
+ assert_eq!(stored.public_locality.primary, "Fixture Town");
+ assert_eq!(
+ sdk.farms()
+ .private_location(&farm_addr)
+ .await
+ .expect("stored lookup")
+ .expect("stored location")
+ .updated_at_ms,
+ 1_700_000_501_000
+ );
+
+ assert!(matches!(
+ sdk.farms()
+ .upsert_private_location_with_geocoder(
+ FarmPrivateLocationUpsertRequest::new(
+ actor.clone(),
+ FARM_B_D_TAG,
+ SdkExactLocation::new(12.26, -34.51),
+ )
+ .with_updated_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)),
+ &geocoder,
+ )
+ .await,
+ Err(RadrootsSdkError::TimestampOutOfRange { .. })
+ ));
+ assert!(matches!(
+ sdk.farms()
+ .upsert_private_location_with_geocoder(
+ FarmPrivateLocationUpsertRequest::new(
+ actor.clone(),
+ FARM_B_D_TAG,
+ SdkExactLocation::new(12.26, -34.51),
+ ),
+ &empty_geocoder,
+ )
+ .await,
+ Err(RadrootsSdkError::GeoNames {
+ kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup,
+ ..
+ })
+ ));
+ assert!(matches!(
+ sdk.farms()
+ .upsert_private_location_with_geocoder(
+ FarmPrivateLocationUpsertRequest::new(
+ actor.clone(),
+ "bad d tag",
+ SdkExactLocation::new(12.26, -34.51),
+ ),
+ &geocoder,
+ )
+ .await,
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+ assert!(matches!(
+ sdk.farms()
+ .upsert_private_location_with_geocoder(
+ FarmPrivateLocationUpsertRequest::new(
+ actor.clone(),
+ FARM_B_D_TAG,
+ SdkExactLocation::new(12.26, -34.51),
+ ),
+ &blank_geocoder,
+ )
+ .await,
+ Err(RadrootsSdkError::GeoNames {
+ kind: crate::RadrootsSdkGeoNamesErrorKind::Lookup,
+ ..
+ })
+ ));
+ let clock_error_sdk = crate::RadrootsClient::builder()
+ .clock(crate::RadrootsSdkClock::BeforeUnixEpoch)
+ .build()
+ .await
+ .expect("clock error sdk");
+ assert!(matches!(
+ clock_error_sdk
+ .farms()
+ .upsert_private_location_with_geocoder(
+ FarmPrivateLocationUpsertRequest::new(
+ actor.clone(),
+ FARM_B_D_TAG,
+ SdkExactLocation::new(12.26, -34.51),
+ ),
+ &geocoder,
+ )
+ .await,
+ Err(RadrootsSdkError::ClockBeforeUnixEpoch)
+ ));
+
+ sdk._private_store.pool().close().await;
+ assert!(matches!(
+ sdk.farms()
+ .upsert_private_location_with_geocoder(
+ FarmPrivateLocationUpsertRequest::new(
+ actor.clone(),
+ FARM_C_D_TAG,
+ SdkExactLocation::new(12.26, -34.51),
+ ),
+ &geocoder,
+ )
+ .await,
+ Err(RadrootsSdkError::PrivateStore { .. })
+ ));
+ assert!(matches!(
+ sdk.farms().private_location(&farm_addr).await,
+ Err(RadrootsSdkError::PrivateStore { .. })
+ ));
+}
diff --git a/crates/sdk/tests/unit/listings_runtime_tests.rs b/crates/sdk/tests/unit/listings_runtime_tests.rs
@@ -332,3 +332,47 @@ async fn listing_configured_local_signer_enqueues_publish_without_explicit_signe
assert_eq!(receipt.signed_event_id, receipt.expected_event_id);
assert_eq!(receipt.state, SdkMutationState::StoredAndQueued);
}
+
+#[tokio::test]
+async fn listing_configured_enqueue_reports_missing_signer_after_prepare() {
+ let sdk = crate::RadrootsClient::builder()
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_500))
+ .build()
+ .await
+ .expect("sdk");
+ let actor = actor();
+ assert!(matches!(
+ sdk.listings()
+ .enqueue_publish(
+ ListingEnqueuePublishRequest::new(
+ actor.clone(),
+ listing(LISTING_A_D_TAG, "Configured Prepare Error Greens"),
+ SdkRelayTargetPolicy::try_explicit([RELAY_A], SdkRelayUrlPolicy::Public)
+ .expect("target relays"),
+ )
+ .with_created_at(RadrootsSdkTimestamp::from_unix_seconds(u64::MAX)),
+ )
+ .await,
+ Err(RadrootsSdkError::TimestampOutOfRange { .. })
+ ));
+ let plan = sdk
+ .listings()
+ .prepare_publish(ListingPreparePublishRequest::new(
+ actor.clone(),
+ listing(LISTING_A_D_TAG, "Missing Signer Greens"),
+ ))
+ .expect("plan");
+
+ assert!(matches!(
+ sdk.listings()
+ .enqueue_prepared_publish(
+ &actor,
+ plan,
+ SdkRelayTargetPolicy::try_explicit([RELAY_A], SdkRelayUrlPolicy::Public)
+ .expect("target relays"),
+ None,
+ )
+ .await,
+ Err(RadrootsSdkError::SignerUnavailable { .. })
+ ));
+}
diff --git a/crates/sdk/tests/unit/market_runtime_tests.rs b/crates/sdk/tests/unit/market_runtime_tests.rs
@@ -0,0 +1,97 @@
+use super::*;
+
+const SELLER_PUBLIC_KEY_HEX: &str =
+ "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af";
+
+fn projection_row() -> RadrootsListingProjectionRow {
+ RadrootsListingProjectionRow {
+ listing_addr: format!("30402:{SELLER_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAg"),
+ listing_event_id: "a".repeat(64),
+ seller_pubkey: SELLER_PUBLIC_KEY_HEX.to_owned(),
+ title: "Blueberries".to_owned(),
+ description: "Fresh field berries".to_owned(),
+ product_type: "fruit".to_owned(),
+ price_amount: "6".to_owned(),
+ price_currency: "USD".to_owned(),
+ inventory_available: "12".to_owned(),
+ delivery_method: "pickup".to_owned(),
+ locality_primary: "Fernwood".to_owned(),
+ locality_city: Some("Victoria".to_owned()),
+ locality_region: Some("BC".to_owned()),
+ locality_country: Some("CA".to_owned()),
+ geohash5: "c2b2q".to_owned(),
+ updated_at_ms: 1_700_000_000_000,
+ }
+}
+
+fn projection_error_message(error: RadrootsSdkError) -> String {
+ match error {
+ RadrootsSdkError::Projection { message } => message,
+ other => panic!("expected projection error, got {other:?}"),
+ }
+}
+
+#[test]
+fn market_search_request_builders_preserve_refresh_contract() {
+ let request = MarketSearchRequest::new("berries")
+ .with_limit(7)
+ .with_projection_refresh(SyncProjectionRefreshRequest::new().with_limit(3));
+
+ assert_eq!(request.query, "berries");
+ assert_eq!(request.limit, 7);
+ assert_eq!(request.projection_refresh.limit, 3);
+}
+
+#[test]
+fn listing_projection_row_conversion_validates_stored_identity_columns() {
+ let row = projection_row();
+ let search_row =
+ MarketListingSearchRow::try_from_projection_row(row.clone()).expect("search row");
+ assert_eq!(search_row.listing_addr.as_str(), row.listing_addr);
+ assert_eq!(search_row.listing_event_id.as_str(), row.listing_event_id);
+ assert_eq!(search_row.seller_pubkey.as_str(), row.seller_pubkey);
+ assert_eq!(search_row.title, "Blueberries");
+
+ let mut invalid_addr = row.clone();
+ invalid_addr.listing_addr = "not-an-address".to_owned();
+ assert!(
+ projection_error_message(
+ MarketListingSearchRow::try_from_projection_row(invalid_addr).unwrap_err()
+ )
+ .contains("projection address")
+ );
+
+ let mut invalid_event_id = row.clone();
+ invalid_event_id.listing_event_id = "not-an-event-id".to_owned();
+ assert!(
+ projection_error_message(
+ MarketListingSearchRow::try_from_projection_row(invalid_event_id).unwrap_err()
+ )
+ .contains("projection event id")
+ );
+
+ let mut invalid_seller = row;
+ invalid_seller.seller_pubkey = "not-a-pubkey".to_owned();
+ assert!(
+ projection_error_message(
+ MarketListingSearchRow::try_from_projection_row(invalid_seller).unwrap_err()
+ )
+ .contains("projection seller pubkey")
+ );
+}
+
+#[tokio::test]
+async fn market_search_reports_projection_refresh_errors_before_querying_rows() {
+ let sdk = crate::RadrootsClient::builder()
+ .clock(crate::RadrootsSdkClock::BeforeUnixEpoch)
+ .build()
+ .await
+ .expect("sdk");
+
+ assert!(matches!(
+ sdk.market()
+ .search(MarketSearchRequest::new("berries"))
+ .await,
+ Err(RadrootsSdkError::ClockBeforeUnixEpoch)
+ ));
+}
diff --git a/crates/sdk/tests/unit/orders_runtime_tests.rs b/crates/sdk/tests/unit/orders_runtime_tests.rs
@@ -422,16 +422,20 @@ async fn prepared_order_sdk() -> RadrootsClient {
.expect("sdk")
}
-#[tokio::test]
-async fn order_configured_local_signer_enqueues_submit_without_explicit_signer() {
- let sdk = RadrootsClient::builder()
+async fn configured_order_sdk(secret_key_hex: &str) -> RadrootsClient {
+ RadrootsClient::builder()
.fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
.signer_provider(RadrootsSdkSignerProvider::LocalKey(
- RadrootsSdkLocalKeySigner::new(keys_from_secret(BUYER_SECRET_KEY_HEX)).expect("signer"),
+ RadrootsSdkLocalKeySigner::new(keys_from_secret(secret_key_hex)).expect("signer"),
))
.build()
.await
- .expect("sdk");
+ .expect("sdk")
+}
+
+#[tokio::test]
+async fn order_configured_local_signer_enqueues_submit_without_explicit_signer() {
+ let sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await;
let receipt = sdk
.trades()
@@ -448,6 +452,110 @@ async fn order_configured_local_signer_enqueues_submit_without_explicit_signer()
assert_eq!(receipt.state, SdkMutationState::StoredAndQueued);
}
+#[tokio::test]
+async fn order_configured_local_signer_enqueues_lifecycle_wrappers_without_explicit_signers() {
+ let seller_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await;
+ let decision_submit = enqueue_fixture_submit(&seller_sdk, "order-configured-decision").await;
+ let decision = seller_sdk
+ .trades()
+ .enqueue_decision(OrderDecisionEnqueueRequest::new(
+ fixture_seller_actor(),
+ fixture_order_event_ptr(&decision_submit.signed_event_id),
+ fixture_order_decision("order-configured-decision"),
+ fixture_target_relays(),
+ ))
+ .await
+ .expect("configured decision");
+ assert_eq!(decision.request_event_id, decision_submit.signed_event_id);
+ assert_eq!(decision.state, SdkMutationState::StoredAndQueued);
+
+ let proposal_submit = enqueue_fixture_submit(&seller_sdk, "order-configured-proposal").await;
+ let proposal_payload = fixture_revision_proposal(
+ "order-configured-proposal",
+ &proposal_submit.signed_event_id,
+ &proposal_submit.signed_event_id,
+ );
+ let proposal = seller_sdk
+ .trades()
+ .enqueue_revision_proposal(OrderRevisionProposalEnqueueRequest::new(
+ fixture_seller_actor(),
+ fixture_order_event_ptr(&proposal_submit.signed_event_id),
+ fixture_order_event_ptr(&proposal_submit.signed_event_id),
+ proposal_payload,
+ fixture_target_relays(),
+ ))
+ .await
+ .expect("configured revision proposal");
+ assert_eq!(proposal.root_event_id, proposal_submit.signed_event_id);
+ assert_eq!(proposal.state, SdkMutationState::StoredAndQueued);
+
+ let buyer_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await;
+ let revision_submit =
+ enqueue_fixture_submit(&buyer_sdk, "order-configured-revision-decision").await;
+ let revision_proposal_payload = fixture_revision_proposal(
+ "order-configured-revision-decision",
+ &revision_submit.signed_event_id,
+ &revision_submit.signed_event_id,
+ );
+ let revision_proposal = buyer_sdk
+ .trades()
+ .enqueue_revision_proposal_with_explicit_signer(
+ OrderRevisionProposalEnqueueRequest::new(
+ fixture_seller_actor(),
+ fixture_order_event_ptr(&revision_submit.signed_event_id),
+ fixture_order_event_ptr(&revision_submit.signed_event_id),
+ revision_proposal_payload.clone(),
+ fixture_target_relays(),
+ ),
+ &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("explicit revision proposal");
+ let revision_decision_payload = fixture_revision_decision(
+ &revision_proposal_payload,
+ &revision_proposal.signed_event_id,
+ );
+ let revision_decision = buyer_sdk
+ .trades()
+ .enqueue_revision_decision(OrderRevisionDecisionEnqueueRequest::new(
+ fixture_buyer_actor(),
+ fixture_order_event_ptr(&revision_submit.signed_event_id),
+ fixture_order_event_ptr(&revision_proposal.signed_event_id),
+ revision_decision_payload,
+ fixture_target_relays(),
+ ))
+ .await
+ .expect("configured revision decision");
+ assert_eq!(
+ revision_decision.root_event_id,
+ revision_submit.signed_event_id
+ );
+ assert_eq!(
+ revision_decision.previous_event_id,
+ revision_proposal.signed_event_id
+ );
+ assert_eq!(revision_decision.state, SdkMutationState::StoredAndQueued);
+
+ let cancel_submit = enqueue_fixture_submit(&buyer_sdk, "order-configured-cancel").await;
+ let cancellation = buyer_sdk
+ .trades()
+ .enqueue_cancellation(OrderCancellationEnqueueRequest::new(
+ fixture_buyer_actor(),
+ fixture_order_event_ptr(&cancel_submit.signed_event_id),
+ fixture_order_event_ptr(&cancel_submit.signed_event_id),
+ fixture_cancellation("order-configured-cancel"),
+ fixture_target_relays(),
+ ))
+ .await
+ .expect("configured cancellation");
+ assert_eq!(cancellation.root_event_id, cancel_submit.signed_event_id);
+ assert_eq!(
+ cancellation.previous_event_id,
+ cancel_submit.signed_event_id
+ );
+ assert_eq!(cancellation.state, SdkMutationState::StoredAndQueued);
+}
+
async fn enqueue_fixture_submit(sdk: &RadrootsClient, raw_order_id: &str) -> OrderSubmitReceipt {
let buyer = fixture_buyer_actor();
let plan = sdk
@@ -1466,10 +1574,53 @@ fn order_issue_mapping_covers_every_trade_issue_variant() {
single!(CancellationRootMismatch, CancellationRootMismatch),
single!(CancellationPreviousMismatch, CancellationPreviousMismatch),
(
- RadrootsOrderIssue::ForkedLifecycle { event_ids: many },
+ RadrootsOrderIssue::ForkedLifecycle {
+ event_ids: many.clone(),
+ },
SdkOrderStatusIssueKind::ForkedLifecycle,
2,
),
+ single!(
+ ValidationReceiptWithoutPendingAgreement,
+ ValidationReceiptWithoutPendingAgreement
+ ),
+ single!(
+ ValidationReceiptOrderIdMismatch,
+ ValidationReceiptOrderIdMismatch
+ ),
+ single!(ValidationReceiptTypeMismatch, ValidationReceiptTypeMismatch),
+ single!(ValidationReceiptRootMismatch, ValidationReceiptRootMismatch),
+ single!(
+ ValidationReceiptTargetMismatch,
+ ValidationReceiptTargetMismatch
+ ),
+ single!(
+ ValidationReceiptListingMismatch,
+ ValidationReceiptListingMismatch
+ ),
+ (
+ RadrootsOrderIssue::ConflictingValidationReceipts {
+ event_ids: many.clone(),
+ },
+ SdkOrderStatusIssueKind::ConflictingValidationReceipts,
+ 2,
+ ),
+ (
+ RadrootsOrderIssue::DeterministicValidationFailure {
+ event_id: one.clone(),
+ reason: "fixture validation failed".to_owned(),
+ },
+ SdkOrderStatusIssueKind::DeterministicValidationFailure,
+ 1,
+ ),
+ (
+ RadrootsOrderIssue::StaleListingEvent {
+ expected_event_id: one,
+ current_event_id: two,
+ },
+ SdkOrderStatusIssueKind::StaleListingEvent,
+ 2,
+ ),
];
for (issue, expected_kind, expected_event_count) in cases {
@@ -1692,6 +1843,15 @@ fn lifecycle_plan_state_wrappers_reject_invalid_status_terminal_and_pending_stat
.contains("non-terminal")
);
+ let mut proposal_pending = base.clone();
+ proposal_pending.pending_revision_event_id = Some(proposal_plan.expected_event_id.clone());
+ assert!(
+ invalid_request_message(
+ require_revision_proposal_state(&proposal_plan, &proposal_pending).unwrap_err()
+ )
+ .contains("cannot follow pending revision")
+ );
+
let mut revision_ready = base.clone();
revision_ready.status = RadrootsTradeWorkflowState::RevisionProposed;
revision_ready.last_event_id = Some(proposal_plan.expected_event_id.clone());
@@ -2119,7 +2279,7 @@ async fn prepared_submit_and_decision_enqueue_cover_source_attached_success_path
.trades()
.enqueue_prepared_decision_with_explicit_signer(
&seller,
- decision_plan,
+ decision_plan.clone(),
fixture_target_relays(),
Some(SdkIdempotencyKey::new("prepared-decision").expect("idempotency")),
&OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX),
@@ -2155,7 +2315,7 @@ async fn prepared_revision_lifecycle_enqueue_cover_source_attached_success_paths
.trades()
.enqueue_prepared_revision_proposal_with_explicit_signer(
&seller,
- proposal_plan,
+ proposal_plan.clone(),
fixture_target_relays(),
Some(SdkIdempotencyKey::new("prepared-proposal").expect("idempotency")),
&OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX),
@@ -2365,7 +2525,7 @@ async fn prepared_lifecycle_enqueues_report_missing_and_closed_preflight_errors(
.trades()
.enqueue_prepared_decision_with_explicit_signer(
&seller,
- decision_plan,
+ decision_plan.clone(),
fixture_target_relays(),
None,
&OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX),
@@ -2390,7 +2550,7 @@ async fn prepared_lifecycle_enqueues_report_missing_and_closed_preflight_errors(
.trades()
.enqueue_prepared_revision_proposal_with_explicit_signer(
&seller,
- proposal_plan,
+ proposal_plan.clone(),
fixture_target_relays(),
None,
&OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX),
@@ -2558,6 +2718,401 @@ async fn prepared_lifecycle_enqueues_report_missing_and_closed_preflight_errors(
}
#[tokio::test]
+async fn configured_prepared_lifecycle_enqueues_run_preflight_guards() {
+ let buyer = fixture_buyer_actor();
+ let seller = fixture_seller_actor();
+ let root_event_id = event_id('e');
+ let previous_event_id = event_id('f');
+ let root = fixture_order_event_ptr(&root_event_id);
+ let previous = fixture_order_event_ptr(&previous_event_id);
+ let proposal = fixture_revision_proposal(
+ "order-configured-preflight",
+ &root_event_id,
+ &previous_event_id,
+ );
+
+ let seller_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await;
+ let decision_plan = seller_sdk
+ .trades()
+ .prepare_decision(OrderDecisionPrepareRequest::new(
+ seller.clone(),
+ root.clone(),
+ fixture_order_decision("order-configured-preflight"),
+ ))
+ .expect("decision plan");
+ let decision_missing = seller_sdk
+ .trades()
+ .enqueue_prepared_decision(&seller, decision_plan, fixture_target_relays(), None)
+ .await
+ .expect_err("configured missing decision evidence");
+ assert!(matches!(
+ decision_missing,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let proposal_plan = seller_sdk
+ .trades()
+ .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new(
+ seller.clone(),
+ root.clone(),
+ previous.clone(),
+ proposal.clone(),
+ ))
+ .expect("proposal plan");
+ let proposal_missing = seller_sdk
+ .trades()
+ .enqueue_prepared_revision_proposal(&seller, proposal_plan, fixture_target_relays(), None)
+ .await
+ .expect_err("configured missing proposal evidence");
+ assert!(matches!(
+ proposal_missing,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let buyer_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await;
+ let revision_plan = buyer_sdk
+ .trades()
+ .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new(
+ buyer.clone(),
+ root.clone(),
+ previous.clone(),
+ fixture_revision_decision(&proposal, &previous_event_id),
+ ))
+ .expect("revision decision plan");
+ let revision_missing = buyer_sdk
+ .trades()
+ .enqueue_prepared_revision_decision(&buyer, revision_plan, fixture_target_relays(), None)
+ .await
+ .expect_err("configured missing revision decision evidence");
+ assert!(matches!(
+ revision_missing,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+
+ let cancellation_plan = buyer_sdk
+ .trades()
+ .prepare_cancellation(OrderCancellationPrepareRequest::new(
+ buyer.clone(),
+ root,
+ previous,
+ fixture_cancellation("order-configured-preflight"),
+ ))
+ .expect("cancellation plan");
+ let cancellation_missing = buyer_sdk
+ .trades()
+ .enqueue_prepared_cancellation(&buyer, cancellation_plan, fixture_target_relays(), None)
+ .await
+ .expect_err("configured missing cancellation evidence");
+ assert!(matches!(
+ cancellation_missing,
+ RadrootsSdkError::InvalidRequest { .. }
+ ));
+}
+
+#[tokio::test]
+async fn configured_prepared_lifecycle_enqueues_report_existing_event_lookup_errors() {
+ let buyer = fixture_buyer_actor();
+ let seller = fixture_seller_actor();
+ let root_event_id = event_id('e');
+ let previous_event_id = event_id('f');
+ let root = fixture_order_event_ptr(&root_event_id);
+ let previous = fixture_order_event_ptr(&previous_event_id);
+ let proposal = fixture_revision_proposal(
+ "order-configured-existing-lookup",
+ &root_event_id,
+ &previous_event_id,
+ );
+
+ let seller_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await;
+ let decision_plan = seller_sdk
+ .trades()
+ .prepare_decision(OrderDecisionPrepareRequest::new(
+ seller.clone(),
+ root.clone(),
+ fixture_order_decision("order-configured-existing-lookup"),
+ ))
+ .expect("decision plan");
+ let proposal_plan = seller_sdk
+ .trades()
+ .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new(
+ seller.clone(),
+ root.clone(),
+ previous.clone(),
+ proposal.clone(),
+ ))
+ .expect("proposal plan");
+ seller_sdk._event_store.pool().close().await;
+ assert!(matches!(
+ seller_sdk
+ .trades()
+ .enqueue_prepared_decision(&seller, decision_plan, fixture_target_relays(), None)
+ .await,
+ Err(RadrootsSdkError::EventStore { .. })
+ ));
+ assert!(matches!(
+ seller_sdk
+ .trades()
+ .enqueue_prepared_revision_proposal(
+ &seller,
+ proposal_plan,
+ fixture_target_relays(),
+ None,
+ )
+ .await,
+ Err(RadrootsSdkError::EventStore { .. })
+ ));
+
+ let buyer_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await;
+ let revision_plan = buyer_sdk
+ .trades()
+ .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new(
+ buyer.clone(),
+ root.clone(),
+ previous.clone(),
+ fixture_revision_decision(&proposal, &previous_event_id),
+ ))
+ .expect("revision decision plan");
+ let cancellation_plan = buyer_sdk
+ .trades()
+ .prepare_cancellation(OrderCancellationPrepareRequest::new(
+ buyer.clone(),
+ root,
+ previous,
+ fixture_cancellation("order-configured-existing-lookup"),
+ ))
+ .expect("cancellation plan");
+ buyer_sdk._event_store.pool().close().await;
+ assert!(matches!(
+ buyer_sdk
+ .trades()
+ .enqueue_prepared_revision_decision(
+ &buyer,
+ revision_plan,
+ fixture_target_relays(),
+ None
+ )
+ .await,
+ Err(RadrootsSdkError::EventStore { .. })
+ ));
+ assert!(matches!(
+ buyer_sdk
+ .trades()
+ .enqueue_prepared_cancellation(&buyer, cancellation_plan, fixture_target_relays(), None)
+ .await,
+ Err(RadrootsSdkError::EventStore { .. })
+ ));
+}
+
+#[tokio::test]
+async fn configured_enqueue_wrappers_report_prepare_errors_before_signing() {
+ let seller_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await;
+ let buyer_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await;
+ let root_event_id = event_id('a');
+ let previous_event_id = event_id('b');
+ let proposal = fixture_revision_proposal(
+ "order-configured-prepare-errors",
+ &root_event_id,
+ &previous_event_id,
+ );
+
+ assert!(matches!(
+ buyer_sdk
+ .trades()
+ .enqueue_submit(OrderSubmitEnqueueRequest::new(
+ fixture_seller_actor(),
+ fixture_event_ptr('a'),
+ fixture_order_request("order-configured-prepare-submit"),
+ fixture_target_relays(),
+ ))
+ .await,
+ Err(RadrootsSdkError::UnauthorizedActor { .. })
+ ));
+ assert!(matches!(
+ seller_sdk
+ .trades()
+ .enqueue_decision(OrderDecisionEnqueueRequest::new(
+ fixture_buyer_actor(),
+ fixture_event_ptr('a'),
+ fixture_order_decision("order-configured-prepare-decision"),
+ fixture_target_relays(),
+ ))
+ .await,
+ Err(RadrootsSdkError::UnauthorizedActor { .. })
+ ));
+ assert!(matches!(
+ seller_sdk
+ .trades()
+ .enqueue_revision_proposal(OrderRevisionProposalEnqueueRequest::new(
+ fixture_buyer_actor(),
+ fixture_order_event_ptr(&root_event_id),
+ fixture_order_event_ptr(&previous_event_id),
+ proposal.clone(),
+ fixture_target_relays(),
+ ))
+ .await,
+ Err(RadrootsSdkError::UnauthorizedActor { .. })
+ ));
+ assert!(matches!(
+ buyer_sdk
+ .trades()
+ .enqueue_revision_decision(OrderRevisionDecisionEnqueueRequest::new(
+ fixture_seller_actor(),
+ fixture_order_event_ptr(&root_event_id),
+ fixture_order_event_ptr(&previous_event_id),
+ fixture_revision_decision(&proposal, &previous_event_id),
+ fixture_target_relays(),
+ ))
+ .await,
+ Err(RadrootsSdkError::UnauthorizedActor { .. })
+ ));
+ assert!(matches!(
+ buyer_sdk
+ .trades()
+ .enqueue_cancellation(OrderCancellationEnqueueRequest::new(
+ fixture_seller_actor(),
+ fixture_order_event_ptr(&root_event_id),
+ fixture_order_event_ptr(&previous_event_id),
+ fixture_cancellation("order-configured-prepare-cancel"),
+ fixture_target_relays(),
+ ))
+ .await,
+ Err(RadrootsSdkError::UnauthorizedActor { .. })
+ ));
+}
+
+#[tokio::test]
+async fn configured_prepared_methods_report_missing_configured_signer_after_preflight() {
+ let sdk = prepared_order_sdk().await;
+ let buyer = fixture_buyer_actor();
+ let seller = fixture_seller_actor();
+
+ let submit_plan = sdk
+ .trades()
+ .prepare_submit(OrderSubmitPrepareRequest::new(
+ buyer.clone(),
+ fixture_event_ptr('a'),
+ fixture_order_request("order-missing-configured-submit"),
+ ))
+ .expect("submit plan");
+ assert!(matches!(
+ sdk.trades()
+ .enqueue_prepared_submit(&buyer, submit_plan, fixture_target_relays(), None)
+ .await,
+ Err(RadrootsSdkError::SignerUnavailable { .. })
+ ));
+
+ let decision_submit = enqueue_fixture_submit(&sdk, "order-missing-configured-decision").await;
+ let decision_plan = sdk
+ .trades()
+ .prepare_decision(OrderDecisionPrepareRequest::new(
+ seller.clone(),
+ fixture_order_event_ptr(&decision_submit.signed_event_id),
+ fixture_order_decision("order-missing-configured-decision"),
+ ))
+ .expect("decision plan");
+ assert!(matches!(
+ sdk.trades()
+ .enqueue_prepared_decision(&seller, decision_plan, fixture_target_relays(), None)
+ .await,
+ Err(RadrootsSdkError::SignerUnavailable { .. })
+ ));
+
+ let proposal_submit = enqueue_fixture_submit(&sdk, "order-missing-configured-proposal").await;
+ let proposal_payload = fixture_revision_proposal(
+ "order-missing-configured-proposal",
+ &proposal_submit.signed_event_id,
+ &proposal_submit.signed_event_id,
+ );
+ let proposal_plan = sdk
+ .trades()
+ .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new(
+ seller.clone(),
+ fixture_order_event_ptr(&proposal_submit.signed_event_id),
+ fixture_order_event_ptr(&proposal_submit.signed_event_id),
+ proposal_payload.clone(),
+ ))
+ .expect("proposal plan");
+ assert!(matches!(
+ sdk.trades()
+ .enqueue_prepared_revision_proposal(
+ &seller,
+ proposal_plan,
+ fixture_target_relays(),
+ None
+ )
+ .await,
+ Err(RadrootsSdkError::SignerUnavailable { .. })
+ ));
+
+ let revision_submit = enqueue_fixture_submit(&sdk, "order-missing-configured-revision").await;
+ let revision_proposal_payload = fixture_revision_proposal(
+ "order-missing-configured-revision",
+ &revision_submit.signed_event_id,
+ &revision_submit.signed_event_id,
+ );
+ let revision_proposal_plan = sdk
+ .trades()
+ .prepare_revision_proposal(OrderRevisionProposalPrepareRequest::new(
+ seller.clone(),
+ fixture_order_event_ptr(&revision_submit.signed_event_id),
+ fixture_order_event_ptr(&revision_submit.signed_event_id),
+ revision_proposal_payload.clone(),
+ ))
+ .expect("revision proposal plan");
+ let revision_proposal = sdk
+ .trades()
+ .enqueue_prepared_revision_proposal_with_explicit_signer(
+ &seller,
+ revision_proposal_plan,
+ fixture_target_relays(),
+ None,
+ &OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("revision proposal evidence");
+ let revision_plan = sdk
+ .trades()
+ .prepare_revision_decision(OrderRevisionDecisionPrepareRequest::new(
+ buyer.clone(),
+ fixture_order_event_ptr(&revision_submit.signed_event_id),
+ fixture_order_event_ptr(&revision_proposal.signed_event_id),
+ fixture_revision_decision(
+ &revision_proposal_payload,
+ &revision_proposal.signed_event_id,
+ ),
+ ))
+ .expect("revision plan");
+ assert!(matches!(
+ sdk.trades()
+ .enqueue_prepared_revision_decision(
+ &buyer,
+ revision_plan,
+ fixture_target_relays(),
+ None
+ )
+ .await,
+ Err(RadrootsSdkError::SignerUnavailable { .. })
+ ));
+
+ let cancellation_submit = enqueue_fixture_submit(&sdk, "order-missing-configured-cancel").await;
+ let cancellation_plan = sdk
+ .trades()
+ .prepare_cancellation(OrderCancellationPrepareRequest::new(
+ buyer.clone(),
+ fixture_order_event_ptr(&cancellation_submit.signed_event_id),
+ fixture_order_event_ptr(&cancellation_submit.signed_event_id),
+ fixture_cancellation("order-missing-configured-cancel"),
+ ))
+ .expect("cancellation plan");
+ assert!(matches!(
+ sdk.trades()
+ .enqueue_prepared_cancellation(&buyer, cancellation_plan, fixture_target_relays(), None)
+ .await,
+ Err(RadrootsSdkError::SignerUnavailable { .. })
+ ));
+}
+
+#[tokio::test]
async fn lifecycle_preflight_helpers_map_projection_query_failures() {
let sdk = prepared_order_sdk().await;
let client = sdk.trades();
@@ -2707,7 +3262,7 @@ async fn prepared_lifecycle_enqueues_report_closed_outbox_after_preflight() {
.trades()
.enqueue_prepared_revision_decision_with_explicit_signer(
&buyer,
- revision_plan,
+ revision_plan.clone(),
fixture_target_relays(),
None,
&OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX),
@@ -2745,7 +3300,7 @@ async fn prepared_lifecycle_enqueues_report_closed_outbox_after_preflight() {
#[tokio::test]
async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() {
- let decision_sdk = prepared_order_sdk().await;
+ let decision_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await;
let decision_submit = enqueue_fixture_submit(&decision_sdk, "order-existing-decision").await;
let seller = fixture_seller_actor();
let decision_plan = decision_sdk
@@ -2771,7 +3326,7 @@ async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() {
.trades()
.enqueue_prepared_decision_with_explicit_signer(
&seller,
- decision_plan,
+ decision_plan.clone(),
fixture_target_relays(),
None,
&OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX),
@@ -2788,8 +3343,28 @@ async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() {
.idempotency
.replayed_existing_operation
);
+ let configured_decision_repeat = decision_sdk
+ .trades()
+ .enqueue_prepared_decision(
+ &seller,
+ decision_plan.clone(),
+ fixture_target_relays(),
+ None,
+ )
+ .await
+ .expect("configured decision replay");
+ assert_eq!(
+ configured_decision_repeat.workflow.state,
+ SdkMutationState::AlreadyQueued
+ );
+ assert!(
+ configured_decision_repeat
+ .workflow
+ .idempotency
+ .replayed_existing_operation
+ );
- let proposal_sdk = prepared_order_sdk().await;
+ let proposal_sdk = configured_order_sdk(SELLER_SECRET_KEY_HEX).await;
let proposal_submit = enqueue_fixture_submit(&proposal_sdk, "order-existing-proposal").await;
let proposal_payload = fixture_revision_proposal(
"order-existing-proposal",
@@ -2820,7 +3395,7 @@ async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() {
.trades()
.enqueue_prepared_revision_proposal_with_explicit_signer(
&seller,
- proposal_plan,
+ proposal_plan.clone(),
fixture_target_relays(),
None,
&OrderFixtureSigner::new(SELLER_SECRET_KEY_HEX),
@@ -2837,8 +3412,28 @@ async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() {
.idempotency
.replayed_existing_operation
);
+ let configured_proposal_repeat = proposal_sdk
+ .trades()
+ .enqueue_prepared_revision_proposal(
+ &seller,
+ proposal_plan.clone(),
+ fixture_target_relays(),
+ None,
+ )
+ .await
+ .expect("configured proposal replay");
+ assert_eq!(
+ configured_proposal_repeat.workflow.state,
+ SdkMutationState::AlreadyQueued
+ );
+ assert!(
+ configured_proposal_repeat
+ .workflow
+ .idempotency
+ .replayed_existing_operation
+ );
- let revision_sdk = prepared_order_sdk().await;
+ let revision_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await;
let revision_submit = enqueue_fixture_submit(&revision_sdk, "order-existing-revision").await;
let proposal_payload = fixture_revision_proposal(
"order-existing-revision",
@@ -2890,7 +3485,7 @@ async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() {
.trades()
.enqueue_prepared_revision_decision_with_explicit_signer(
&buyer,
- revision_plan,
+ revision_plan.clone(),
fixture_target_relays(),
None,
&OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX),
@@ -2907,6 +3502,65 @@ async fn prepared_lifecycle_enqueues_skip_preflight_for_existing_events() {
.idempotency
.replayed_existing_operation
);
+ let configured_revision_repeat = revision_sdk
+ .trades()
+ .enqueue_prepared_revision_decision(
+ &buyer,
+ revision_plan.clone(),
+ fixture_target_relays(),
+ None,
+ )
+ .await
+ .expect("configured revision replay");
+ assert_eq!(
+ configured_revision_repeat.workflow.state,
+ SdkMutationState::AlreadyQueued
+ );
+ assert!(
+ configured_revision_repeat
+ .workflow
+ .idempotency
+ .replayed_existing_operation
+ );
+
+ let cancellation_sdk = configured_order_sdk(BUYER_SECRET_KEY_HEX).await;
+ let cancellation_submit =
+ enqueue_fixture_submit(&cancellation_sdk, "order-existing-cancellation").await;
+ let cancellation_plan = cancellation_sdk
+ .trades()
+ .prepare_cancellation(OrderCancellationPrepareRequest::new(
+ buyer.clone(),
+ fixture_order_event_ptr(&cancellation_submit.signed_event_id),
+ fixture_order_event_ptr(&cancellation_submit.signed_event_id),
+ fixture_cancellation("order-existing-cancellation"),
+ ))
+ .expect("cancellation plan");
+ cancellation_sdk
+ .trades()
+ .enqueue_prepared_cancellation_with_explicit_signer(
+ &buyer,
+ cancellation_plan.clone(),
+ fixture_target_relays(),
+ None,
+ &OrderFixtureSigner::new(BUYER_SECRET_KEY_HEX),
+ )
+ .await
+ .expect("enqueue cancellation");
+ let cancellation_repeat = cancellation_sdk
+ .trades()
+ .enqueue_prepared_cancellation(&buyer, cancellation_plan, fixture_target_relays(), None)
+ .await
+ .expect("configured cancellation replay");
+ assert_eq!(
+ cancellation_repeat.workflow.state,
+ SdkMutationState::AlreadyQueued
+ );
+ assert!(
+ cancellation_repeat
+ .workflow
+ .idempotency
+ .replayed_existing_operation
+ );
}
#[tokio::test]
diff --git a/crates/sdk/tests/unit/private_store_tests.rs b/crates/sdk/tests/unit/private_store_tests.rs
@@ -0,0 +1,69 @@
+use super::*;
+use radroots_events::ids::RadrootsAddressableCoordinate;
+
+fn farm_addr() -> RadrootsAddressableCoordinate {
+ RadrootsAddressableCoordinate::parse(format!(
+ "{}:{}:{}",
+ radroots_events::kinds::KIND_FARM,
+ "a".repeat(64),
+ "AAAAAAAAAAAAAAAAAAAAAA"
+ ))
+ .expect("farm addr")
+}
+
+fn private_store_error_message<T>(result: Result<T, RadrootsSdkError>) -> String {
+ match result.err().expect("private store error") {
+ RadrootsSdkError::PrivateStore { message } => message,
+ other => panic!("expected private store error, got {other:?}"),
+ }
+}
+
+#[tokio::test]
+async fn private_farm_location_row_decode_reports_each_missing_column() {
+ let store = SdkPrivateStore::open_memory().await.expect("private store");
+ let columns = [
+ (
+ "farm_pubkey",
+ "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'",
+ ),
+ ("farm_d_tag", "'AAAAAAAAAAAAAAAAAAAAAA'"),
+ ("latitude", "12.26"),
+ ("longitude", "-34.51"),
+ ("locality_primary", "'Fixture Town'"),
+ ("locality_city", "'Fixture Town'"),
+ ("locality_region", "'Fixture Region'"),
+ ("locality_country", "'Fixture Country'"),
+ ("geohash5", "'e4pmw'"),
+ ("geonames_feature_id", "1"),
+ ("geonames_country_id", "'FX'"),
+ ("updated_at_ms", "1700000123000"),
+ ];
+
+ for missing in columns.map(|(name, _)| name) {
+ let select = columns
+ .iter()
+ .filter(|(name, _)| *name != missing)
+ .map(|(name, value)| format!("{value} AS {name}"))
+ .collect::<Vec<_>>()
+ .join(", ");
+ let sql = format!("SELECT {select}");
+ let row = sqlx::query(sql.as_str())
+ .fetch_one(store.pool())
+ .await
+ .expect("row");
+ let message = private_store_error_message(private_farm_location_from_row(farm_addr(), row));
+ assert!(
+ message.contains(missing),
+ "{message:?} should mention {missing:?}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn private_store_file_open_rejects_directory_paths() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ assert!(matches!(
+ SdkPrivateStore::open_file(tempdir.path()).await,
+ Err(RadrootsSdkError::PrivateStore { .. })
+ ));
+}
diff --git a/crates/sdk/tests/unit/runtime_tests.rs b/crates/sdk/tests/unit/runtime_tests.rs
@@ -179,6 +179,31 @@ async fn private_store_validates_location_rows_and_round_trips_valid_records() {
store.upsert_farm_location(&invalid_coordinates).await,
Err(RadrootsSdkError::InvalidRequest { .. })
));
+ for (latitude, longitude) in [
+ (f64::INFINITY, record.longitude),
+ (record.latitude, f64::NEG_INFINITY),
+ (-90.1, record.longitude),
+ (90.1, record.longitude),
+ (record.latitude, -180.1),
+ (record.latitude, 180.1),
+ ] {
+ let mut invalid = record.clone();
+ invalid.latitude = latitude;
+ invalid.longitude = longitude;
+ assert!(matches!(
+ store.upsert_farm_location(&invalid).await,
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+ }
+ for (latitude, longitude) in [(-90.0, -180.0), (90.0, 180.0)] {
+ let mut boundary = record.clone();
+ boundary.latitude = latitude;
+ boundary.longitude = longitude;
+ store
+ .upsert_farm_location(&boundary)
+ .await
+ .expect("boundary coordinates");
+ }
let mut blank_locality = record.clone();
blank_locality.locality_primary = " ".to_owned();
@@ -187,12 +212,41 @@ async fn private_store_validates_location_rows_and_round_trips_valid_records() {
Err(RadrootsSdkError::InvalidRequest { .. })
));
- let mut invalid_geohash = record;
+ let mut invalid_geohash = record.clone();
invalid_geohash.geohash5 = "abcd".to_owned();
assert!(matches!(
store.upsert_farm_location(&invalid_geohash).await,
Err(RadrootsSdkError::InvalidRequest { .. })
));
+ let mut long_geohash = private_farm_location_record();
+ long_geohash.geohash5 = "abcdef".to_owned();
+ assert!(matches!(
+ store.upsert_farm_location(&long_geohash).await,
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ sqlx::query("DROP TABLE sdk_private_farm_location")
+ .execute(store.pool())
+ .await
+ .expect("drop private location table");
+ assert_private_store_error(store.status_summary().await);
+ assert_private_store_error(store.farm_location(&record.farm_addr).await);
+ assert_private_store_error(store.upsert_farm_location(&record).await);
+}
+
+#[test]
+fn publish_transport_defaults_and_delegated_resolution_are_explicit() {
+ let direct = SdkPublishTransport::default();
+ assert_eq!(direct, SdkPublishTransport::DirectNostrRelay);
+ assert!(!direct.supports_delegated_relay_resolution());
+
+ #[cfg(feature = "radrootsd-proxy")]
+ {
+ let proxy = SdkPublishTransport::RadrootsdProxy(RadrootsdProxyConfig::new(
+ "http://127.0.0.1:9/rpc",
+ ));
+ assert!(proxy.supports_delegated_relay_resolution());
+ }
}
#[tokio::test]
@@ -449,6 +503,165 @@ async fn storage_status_integrity_and_backup_map_closed_pool_errors() {
outbox_closed.integrity(IntegrityRequest::new()).await,
Err(RadrootsSdkError::EventStore { .. })
));
+
+ let private_store_closed = RadrootsClient::builder().build().await.expect("sdk");
+ private_store_closed._private_store.pool().close().await;
+ assert!(matches!(
+ private_store_closed
+ .storage_status(StorageStatusRequest::new())
+ .await,
+ Err(RadrootsSdkError::PrivateStore { .. })
+ ));
+ assert!(matches!(
+ private_store_closed
+ .integrity(IntegrityRequest::new())
+ .await,
+ Err(RadrootsSdkError::PrivateStore { .. })
+ ));
+ assert_private_store_error(
+ private_store_closed
+ ._private_store
+ .pragma_foreign_keys()
+ .await,
+ );
+ assert_private_store_error(
+ private_store_closed
+ ._private_store
+ .pragma_busy_timeout()
+ .await,
+ );
+ assert_private_store_error(
+ private_store_closed
+ ._private_store
+ .pragma_journal_mode()
+ .await,
+ );
+ assert_private_store_error(
+ private_store_sqlite_status(&private_store_closed._private_store).await,
+ );
+ assert_private_store_error(
+ private_sqlite_store_status(
+ private_store_closed._private_store.pool(),
+ SDK_PRIVATE_STORE_SCHEMA_VERSION_CURRENT,
+ "memory".to_owned(),
+ true,
+ 5_000,
+ )
+ .await,
+ );
+ assert_private_store_error(private_store_closed._private_store.status_summary().await);
+ let record = private_farm_location_record();
+ assert_private_store_error(
+ private_store_closed
+ ._private_store
+ .upsert_farm_location(&record)
+ .await,
+ );
+ assert_private_store_error(
+ private_store_closed
+ ._private_store
+ .farm_location(&record.farm_addr)
+ .await,
+ );
+
+ let event_store_summary_error = RadrootsClient::builder().build().await.expect("sdk");
+ sqlx::query("DROP TABLE nostr_events")
+ .execute(event_store_summary_error._event_store.pool())
+ .await
+ .expect("drop nostr events");
+ assert!(matches!(
+ event_store_summary_error
+ .storage_status(StorageStatusRequest::new())
+ .await,
+ Err(RadrootsSdkError::EventStore { .. })
+ ));
+ assert_event_store_error(
+ event_store_status_summary(&event_store_summary_error._event_store).await,
+ );
+
+ let outbox_summary_error = RadrootsClient::builder().build().await.expect("sdk");
+ sqlx::query("DROP TABLE outbox_event")
+ .execute(outbox_summary_error._outbox.pool())
+ .await
+ .expect("drop outbox event");
+ assert!(matches!(
+ outbox_summary_error
+ .storage_status(StorageStatusRequest::new())
+ .await,
+ Err(RadrootsSdkError::Outbox { .. })
+ ));
+ assert_outbox_error(outbox_status_summary(&outbox_summary_error._outbox, 1).await);
+
+ let private_summary_error = RadrootsClient::builder().build().await.expect("sdk");
+ sqlx::query("DROP TABLE sdk_private_farm_location")
+ .execute(private_summary_error._private_store.pool())
+ .await
+ .expect("drop private location");
+ assert!(matches!(
+ private_summary_error
+ .storage_status(StorageStatusRequest::new())
+ .await,
+ Err(RadrootsSdkError::PrivateStore { .. })
+ ));
+ assert_private_store_error(private_summary_error._private_store.status_summary().await);
+
+ let event_store = RadrootsEventStore::open_memory()
+ .await
+ .expect("event store");
+ let outbox = RadrootsOutbox::open_memory().await.expect("outbox");
+ let private_store = SdkPrivateStore::open_memory().await.expect("private store");
+ private_store.pool().close().await;
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ assert_event_store_error(
+ backup_sqlite_stores(
+ event_store.pool(),
+ outbox.pool(),
+ private_store.pool(),
+ &RadrootsSdkStoragePaths {
+ event_store_path: tempdir.path().join(EVENT_STORE_BACKUP_FILE),
+ outbox_path: tempdir.path().join(OUTBOX_BACKUP_FILE),
+ private_store_path: tempdir.path().join(PRIVATE_STORE_BACKUP_FILE),
+ },
+ )
+ .await,
+ );
+}
+
+#[tokio::test]
+async fn verify_backup_paths_reports_each_store_member_failure() {
+ let tempdir = tempfile::tempdir().expect("tempdir");
+ let source_sdk = RadrootsClient::builder()
+ .fixed_clock(RadrootsSdkTimestamp::from_unix_seconds(1_700_000_000))
+ .build()
+ .await
+ .expect("sdk");
+ let backup_destination = tempdir.path().join("backup");
+ source_sdk
+ .backup(BackupRequest::new(&backup_destination))
+ .await
+ .expect("backup");
+
+ let bad_event_store_path = backup_destination.join("bad-event-store.sqlite");
+ fs::create_dir(&bad_event_store_path).expect("bad event store dir");
+ let bad_outbox_path = backup_destination.join("bad-outbox.sqlite");
+ fs::create_dir(&bad_outbox_path).expect("bad outbox dir");
+ let bad_private_store_path = backup_destination.join("bad-private.sqlite");
+ fs::create_dir(&bad_private_store_path).expect("bad private store dir");
+
+ let mut invalid_member = RadrootsSdkStoragePaths {
+ event_store_path: bad_event_store_path,
+ outbox_path: backup_destination.join(OUTBOX_BACKUP_FILE),
+ private_store_path: backup_destination.join(PRIVATE_STORE_BACKUP_FILE),
+ };
+ assert_event_store_error(verify_backup_paths(&invalid_member).await);
+
+ invalid_member.event_store_path = backup_destination.join(EVENT_STORE_BACKUP_FILE);
+ invalid_member.outbox_path = bad_outbox_path;
+ assert_outbox_error(verify_backup_paths(&invalid_member).await);
+
+ invalid_member.outbox_path = backup_destination.join(OUTBOX_BACKUP_FILE);
+ invalid_member.private_store_path = bad_private_store_path;
+ assert_private_store_error(verify_backup_paths(&invalid_member).await);
}
#[test]
@@ -1039,6 +1252,26 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges()
io_message(copy_restore_archive_to_staging(&partial_archive, &staging_paths).await)
.contains("restore outbox copy failed")
);
+ let private_partial_archive = RestoreArchive {
+ event_store_path: tempdir.path().join("private-partial-event-store.sqlite"),
+ outbox_path: tempdir.path().join("private-partial-outbox.sqlite"),
+ ..missing_archive.clone()
+ };
+ fs::write(&private_partial_archive.event_store_path, b"event store")
+ .expect("private partial event store");
+ fs::write(&private_partial_archive.outbox_path, b"outbox").expect("private partial outbox");
+ let private_staging_paths = RadrootsSdkStoragePaths {
+ event_store_path: tempdir.path().join("private-staging-event-store.sqlite"),
+ outbox_path: tempdir.path().join("private-staging-outbox.sqlite"),
+ private_store_path: tempdir.path().join("private-staging-missing.sqlite"),
+ };
+ assert!(
+ io_message(
+ copy_restore_archive_to_staging(&private_partial_archive, &private_staging_paths,)
+ .await
+ )
+ .contains("restore private store copy failed")
+ );
let corrupt_archive = RestoreArchive {
event_store_path: tempdir.path().join("corrupt-event-store.sqlite"),
outbox_path: tempdir.path().join("corrupt-outbox.sqlite"),
@@ -1076,6 +1309,29 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges()
invalid_request_message(inspect_restore_archive(invalid_outbox_member_source).await)
.contains("outbox archive path")
);
+ let invalid_private_member_source = tempdir.path().join("invalid-private-member");
+ fs::create_dir(&invalid_private_member_source).expect("invalid private source");
+ fs::write(
+ invalid_private_member_source.join(EVENT_STORE_BACKUP_FILE),
+ b"not sqlite",
+ )
+ .expect("invalid private event store member");
+ fs::write(
+ invalid_private_member_source.join(OUTBOX_BACKUP_FILE),
+ b"not sqlite",
+ )
+ .expect("invalid private outbox member");
+ let mut invalid_private_manifest = manifest();
+ invalid_private_manifest.backup_paths.private_store_path = PathBuf::from("../outside.sqlite");
+ write_backup_manifest(
+ &invalid_private_member_source.join(BACKUP_MANIFEST_FILE),
+ &invalid_private_manifest,
+ )
+ .expect("invalid private manifest");
+ assert!(
+ invalid_request_message(inspect_restore_archive(invalid_private_member_source).await)
+ .contains("private store archive path")
+ );
let protected_parent = tempdir.path().join("protected-parent");
fs::create_dir(&protected_parent).expect("protected parent");
@@ -1144,6 +1400,11 @@ async fn restore_archive_private_failures_cover_staging_and_verification_edges()
.expect("overwrite existing restore");
assert!(existing_destination.join(EVENT_STORE_BACKUP_FILE).exists());
assert!(existing_destination.join(OUTBOX_BACKUP_FILE).exists());
+ assert!(
+ existing_destination
+ .join(PRIVATE_STORE_BACKUP_FILE)
+ .exists()
+ );
let mut mismatch_archive = archive.clone();
mismatch_archive.verification.event_store_events += 1;
diff --git a/crates/sdk/tests/unit/signer_provider_tests.rs b/crates/sdk/tests/unit/signer_provider_tests.rs
@@ -106,6 +106,25 @@ fn response_event(
.expect("response event")
}
+fn myc_signer_with_responses(
+ responses: Vec<MockNip46Response>,
+) -> (RadrootsSdkMycNip46Signer, Arc<MockNip46Transport>) {
+ let remote_keys = remote_keys();
+ let transport = Arc::new(MockNip46Transport::new(remote_keys.clone(), responses));
+ let target = RadrootsNostrConnectClientTarget::new(
+ remote_keys.public_key(),
+ vec![nostr::RelayUrl::parse("wss://relay.example.com").expect("relay")],
+ );
+ let signer = RadrootsSdkMycNip46Signer::new(
+ client_keys(),
+ target,
+ USER_PUBLIC_KEY_HEX,
+ transport.clone(),
+ )
+ .expect("signer");
+ (signer, transport)
+}
+
struct MockNip46Transport {
remote_keys: RadrootsNostrKeys,
responses: Mutex<VecDeque<MockNip46Response>>,
@@ -222,7 +241,7 @@ async fn local_key_provider_signs_authorized_frozen_draft() {
let receipt = provider
.sign(
RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink(
- &mut |event| {
+ &mut |event: RadrootsSdkSignerProgress| {
progress.push(event);
Ok(())
},
@@ -250,6 +269,243 @@ async fn local_key_provider_signs_authorized_frozen_draft() {
);
}
+#[tokio::test]
+async fn local_key_provider_returns_progress_sink_errors_without_transport_state() {
+ let signer = RadrootsSdkLocalKeySigner::new(user_keys()).expect("signer");
+ let draft = frozen_draft();
+ let actor = actor();
+ let wrong_actor = RadrootsActorContext::test("a".repeat(64), [RadrootsActorRole::Farmer])
+ .expect("wrong actor");
+
+ assert!(matches!(
+ signer
+ .sign(RadrootsSdkSignRequest::new(
+ "farm.publish",
+ &wrong_actor,
+ &draft,
+ ))
+ .await,
+ Err(RadrootsSdkError::UnauthorizedActor { .. })
+ ));
+
+ let started_error = signer
+ .sign(
+ RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink(
+ &mut |event: RadrootsSdkSignerProgress| {
+ assert!(matches!(
+ event,
+ RadrootsSdkSignerProgress::RequestStarted {
+ mode: RadrootsSdkSignerMode::LocalKey
+ }
+ ));
+ Err(RadrootsSdkError::InvalidRequest {
+ message: "local progress start refused".to_owned(),
+ })
+ },
+ ),
+ )
+ .await
+ .expect_err("progress start error");
+ assert!(matches!(
+ started_error,
+ RadrootsSdkError::InvalidRequest { ref message }
+ if message == "local progress start refused"
+ ));
+
+ let mut observed = Vec::new();
+ let completed_error = signer
+ .sign(
+ RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink(
+ &mut |event: RadrootsSdkSignerProgress| {
+ observed.push(event.clone());
+ if matches!(
+ event,
+ RadrootsSdkSignerProgress::RequestCompleted {
+ mode: RadrootsSdkSignerMode::LocalKey
+ }
+ ) {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "local progress completion refused".to_owned(),
+ });
+ }
+ Ok(())
+ },
+ ),
+ )
+ .await
+ .expect_err("progress completion error");
+ assert!(matches!(
+ completed_error,
+ RadrootsSdkError::InvalidRequest { ref message }
+ if message == "local progress completion refused"
+ ));
+ assert_eq!(
+ observed,
+ vec![
+ RadrootsSdkSignerProgress::RequestStarted {
+ mode: RadrootsSdkSignerMode::LocalKey
+ },
+ RadrootsSdkSignerProgress::RequestCompleted {
+ mode: RadrootsSdkSignerMode::LocalKey
+ }
+ ]
+ );
+}
+
+#[test]
+fn signer_provider_reports_myc_status_capability_and_constructor_errors() {
+ assert_eq!(RadrootsSdkSignerMode::LocalKey.as_str(), "local_key");
+ assert_eq!(RadrootsSdkSignerMode::MycNip46.as_str(), "myc_nip46");
+
+ let remote_keys = remote_keys();
+ let relays = vec![
+ nostr::RelayUrl::parse("wss://relay-a.example.com").expect("relay a"),
+ nostr::RelayUrl::parse("wss://relay-b.example.com").expect("relay b"),
+ ];
+ let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), relays);
+ let transport = Arc::new(MockNip46Transport::new(remote_keys.clone(), Vec::new()));
+ let signer = RadrootsSdkMycNip46Signer::new(
+ client_keys(),
+ target,
+ USER_PUBLIC_KEY_HEX,
+ transport.clone(),
+ )
+ .expect("signer");
+ let provider = RadrootsSdkSignerProvider::MycNip46(signer);
+
+ assert_eq!(provider.mode(), RadrootsSdkSignerMode::MycNip46);
+ assert_eq!(
+ provider.status(),
+ RadrootsSdkSignerStatus {
+ mode: RadrootsSdkSignerMode::MycNip46,
+ state: RadrootsSdkSignerState::Ready,
+ signer_pubkey: USER_PUBLIC_KEY_HEX.to_owned(),
+ remote_signer_pubkey: Some(remote_keys.public_key().to_hex()),
+ relay_count: 2,
+ }
+ );
+ assert_eq!(
+ provider.capability(),
+ RadrootsSdkSignerCapability {
+ mode: RadrootsSdkSignerMode::MycNip46,
+ signer_pubkey: USER_PUBLIC_KEY_HEX.to_owned(),
+ remote_signer_pubkey: Some(remote_keys.public_key().to_hex()),
+ relays: vec![
+ "wss://relay-a.example.com".to_owned(),
+ "wss://relay-b.example.com".to_owned(),
+ ],
+ can_sign_events: true,
+ nip46_permissions: radroots_sdk_myc_nip46_product_permission_strings(),
+ }
+ );
+
+ let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new());
+ let error =
+ match RadrootsSdkMycNip46Signer::new(client_keys(), target, "not-a-pubkey", transport) {
+ Ok(_) => panic!("expected invalid pubkey"),
+ Err(error) => error,
+ };
+ assert!(matches!(
+ error,
+ RadrootsSdkError::InvalidRequest { ref message }
+ if message.contains("myc_nip46 user pubkey is invalid")
+ ));
+}
+
+#[test]
+fn nip46_private_helpers_map_identity_adapter_and_response_edges() {
+ let pubkey = USER_PUBLIC_KEY_HEX.parse().expect("pubkey");
+ let identity = RadrootsSdkSignerIdentityOnly { pubkey };
+ assert_eq!(identity.pubkey().as_str(), USER_PUBLIC_KEY_HEX);
+ assert!(matches!(
+ identity.sign_frozen_draft(&frozen_draft()),
+ Err(RadrootsSignerError::Unavailable)
+ ));
+
+ assert!(matches!(
+ signed_event_from_nip46_response(
+ "farm.publish",
+ RadrootsNostrConnectResponse::Error {
+ result: None,
+ error: "operator rejected".to_owned(),
+ },
+ ),
+ Err(RadrootsSdkError::SignerRequestRejected { ref mode, ref reason })
+ if mode == "myc_nip46" && reason == "operator rejected"
+ ));
+ assert!(matches!(
+ signed_event_from_nip46_response("farm.publish", RadrootsNostrConnectResponse::PendingConnection),
+ Err(RadrootsSdkError::SignerAuthChallengePending { ref mode, auth_url: None })
+ if mode == "myc_nip46"
+ ));
+ assert!(matches!(
+ signed_event_from_nip46_response("farm.publish", RadrootsNostrConnectResponse::Pong),
+ Err(RadrootsSdkError::SignerProtocol { ref mode, ref reason })
+ if mode == "myc_nip46" && reason.contains("farm.publish")
+ ));
+ assert!(matches!(
+ sdk_error_from_nip46_error(RadrootsNostrConnectError::Transport {
+ reason: "relay offline".to_owned(),
+ }),
+ RadrootsSdkError::SignerTransport { ref mode, ref reason }
+ if mode == "myc_nip46" && reason == "relay offline"
+ ));
+ assert!(matches!(
+ sdk_error_from_nip46_error(RadrootsNostrConnectError::Json("bad json".to_owned())),
+ RadrootsSdkError::SignerProtocol { ref mode, ref reason }
+ if mode == "myc_nip46" && reason == "bad json"
+ ));
+ for error in [
+ RadrootsNostrConnectError::Encrypt {
+ reason: "encrypt failed".to_owned(),
+ },
+ RadrootsNostrConnectError::Decrypt {
+ reason: "decrypt failed".to_owned(),
+ },
+ RadrootsNostrConnectError::Sign {
+ reason: "sign failed".to_owned(),
+ },
+ RadrootsNostrConnectError::InvalidRequestPayload {
+ method: "sign_event".to_owned(),
+ reason: "request payload failed".to_owned(),
+ },
+ RadrootsNostrConnectError::InvalidResponsePayload {
+ method: "sign_event".to_owned(),
+ reason: "response payload failed".to_owned(),
+ },
+ ] {
+ assert!(matches!(
+ sdk_error_from_nip46_error(error),
+ RadrootsSdkError::SignerProtocol { ref mode, .. } if mode == "myc_nip46"
+ ));
+ }
+ assert!(matches!(
+ sdk_error_from_nip46_error(RadrootsNostrConnectError::InvalidMethod("ping".to_owned())),
+ RadrootsSdkError::SignerProtocol { ref mode, ref reason }
+ if mode == "myc_nip46" && reason.contains("invalid NIP-46 method")
+ ));
+}
+
+#[tokio::test]
+async fn nip46_transport_adapter_delegates_publish_and_response_poll() {
+ let transport = Arc::new(MockNip46Transport::new(remote_keys(), Vec::new()));
+ let event = sign_event(&user_keys(), &frozen_draft());
+ let mut adapter = RadrootsSdkNip46TransportAdapter {
+ transport: transport.as_ref(),
+ };
+
+ adapter
+ .publish_request_event(event)
+ .await
+ .expect("publish request");
+
+ assert_eq!(transport.published().len(), 1);
+ assert!(matches!(
+ adapter.next_response_event().await,
+ Err(RadrootsNostrConnectError::RequestTimedOut)
+ ));
+}
+
#[test]
fn myc_nip46_product_permissions_cover_sdk_write_event_kinds() {
let permissions = radroots_sdk_myc_nip46_product_permissions();
@@ -299,7 +555,7 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() {
let receipt = provider
.sign(
RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink(
- &mut |event| {
+ &mut |event: RadrootsSdkSignerProgress| {
progress.push(event);
Ok(())
},
@@ -336,6 +592,129 @@ async fn myc_nip46_provider_signs_and_validates_remote_event() {
}
#[tokio::test]
+async fn myc_nip46_provider_reports_preflight_and_progress_sink_edges() {
+ let draft = frozen_draft();
+ let actor = actor();
+ let (signer, transport) = myc_signer_with_responses(Vec::new());
+
+ let started_error = signer
+ .sign(
+ RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink(
+ &mut |event: RadrootsSdkSignerProgress| {
+ assert!(matches!(
+ event,
+ RadrootsSdkSignerProgress::RequestStarted {
+ mode: RadrootsSdkSignerMode::MycNip46
+ }
+ ));
+ Err(RadrootsSdkError::InvalidRequest {
+ message: "myc progress start refused".to_owned(),
+ })
+ },
+ ),
+ )
+ .await
+ .expect_err("progress start error");
+ assert!(matches!(
+ started_error,
+ RadrootsSdkError::InvalidRequest { ref message }
+ if message == "myc progress start refused"
+ ));
+ assert!(transport.published().is_empty());
+
+ let wrong_actor = RadrootsActorContext::test("a".repeat(64), [RadrootsActorRole::Farmer])
+ .expect("wrong actor");
+ let actor_error = signer
+ .sign(RadrootsSdkSignRequest::new(
+ "farm.publish",
+ &wrong_actor,
+ &draft,
+ ))
+ .await
+ .expect_err("actor mismatch");
+ assert!(matches!(
+ actor_error,
+ RadrootsSdkError::UnauthorizedActor { .. }
+ ));
+ assert!(transport.published().is_empty());
+
+ let remote_keys = remote_keys();
+ let mismatch_transport = Arc::new(MockNip46Transport::new(remote_keys.clone(), Vec::new()));
+ let mismatch_target =
+ RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new());
+ let mismatch_signer = RadrootsSdkMycNip46Signer::new(
+ client_keys(),
+ mismatch_target,
+ remote_keys.public_key().to_hex(),
+ mismatch_transport.clone(),
+ )
+ .expect("mismatch signer");
+ let signer_error = mismatch_signer
+ .sign(RadrootsSdkSignRequest::new("farm.publish", &actor, &draft))
+ .await
+ .expect_err("signer mismatch");
+ assert!(matches!(
+ signer_error,
+ RadrootsSdkError::SignerPubkeyMismatch { .. }
+ ));
+ assert!(mismatch_transport.published().is_empty());
+}
+
+#[tokio::test]
+async fn myc_nip46_provider_returns_completion_progress_errors_after_remote_sign() {
+ let user_keys = user_keys();
+ let draft = frozen_draft();
+ let signed = radroots_nostr::prelude::radroots_nostr_sign_frozen_draft(&user_keys, &draft)
+ .expect("signed");
+ let signed_event = RadrootsNostrEvent::from_json(signed.raw_json.as_str()).expect("event");
+ let (signer, transport) = myc_signer_with_responses(vec![MockNip46Response::Respond(
+ RadrootsNostrConnectResponse::SignedEvent(signed_event),
+ )]);
+ let actor = actor();
+ let mut observed = Vec::new();
+
+ let error = signer
+ .sign(
+ RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink(
+ &mut |event: RadrootsSdkSignerProgress| {
+ observed.push(event.clone());
+ if matches!(
+ event,
+ RadrootsSdkSignerProgress::RequestCompleted {
+ mode: RadrootsSdkSignerMode::MycNip46
+ }
+ ) {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "myc progress completion refused".to_owned(),
+ });
+ }
+ Ok(())
+ },
+ ),
+ )
+ .await
+ .expect_err("completion progress error");
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::InvalidRequest { ref message }
+ if message == "myc progress completion refused"
+ ));
+ assert_eq!(transport.published().len(), 1);
+ assert_eq!(
+ observed,
+ vec![
+ RadrootsSdkSignerProgress::RequestStarted {
+ mode: RadrootsSdkSignerMode::MycNip46
+ },
+ RadrootsSdkSignerProgress::RequestCompleted {
+ mode: RadrootsSdkSignerMode::MycNip46
+ }
+ ]
+ );
+}
+
+#[tokio::test]
async fn myc_nip46_provider_reports_auth_challenge_progress_and_timeout() {
let client_keys = client_keys();
let remote_keys = remote_keys();
@@ -356,7 +735,7 @@ async fn myc_nip46_provider_reports_auth_challenge_progress_and_timeout() {
let error = signer
.sign(
RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink(
- &mut |event| {
+ &mut |event: RadrootsSdkSignerProgress| {
progress.push(event);
Ok(())
},
@@ -384,6 +763,55 @@ async fn myc_nip46_provider_reports_auth_challenge_progress_and_timeout() {
}
#[tokio::test]
+async fn myc_nip46_provider_returns_progress_sink_errors_from_auth_challenge() {
+ let client_keys = client_keys();
+ let remote_keys = remote_keys();
+ let transport = Arc::new(MockNip46Transport::new(
+ remote_keys.clone(),
+ vec![MockNip46Response::Respond(
+ RadrootsNostrConnectResponse::AuthUrl("https://auth.example.com/challenge".to_owned()),
+ )],
+ ));
+ let target = RadrootsNostrConnectClientTarget::new(remote_keys.public_key(), Vec::new());
+ let signer =
+ RadrootsSdkMycNip46Signer::new(client_keys, target, USER_PUBLIC_KEY_HEX, transport)
+ .expect("signer");
+ let draft = frozen_draft();
+ let actor = actor();
+ let mut observed = Vec::new();
+
+ let error = signer
+ .sign(
+ RadrootsSdkSignRequest::new("farm.publish", &actor, &draft).with_progress_sink(
+ &mut |event: RadrootsSdkSignerProgress| {
+ observed.push(event.clone());
+ if matches!(
+ event,
+ RadrootsSdkSignerProgress::AuthChallenge {
+ mode: RadrootsSdkSignerMode::MycNip46,
+ ..
+ }
+ ) {
+ return Err(RadrootsSdkError::InvalidRequest {
+ message: "progress sink refused auth challenge".to_owned(),
+ });
+ }
+ Ok(())
+ },
+ ),
+ )
+ .await
+ .expect_err("progress sink error");
+
+ assert!(matches!(
+ error,
+ RadrootsSdkError::InvalidRequest { ref message }
+ if message == "progress sink refused auth challenge"
+ ));
+ assert_eq!(observed.len(), 2);
+}
+
+#[tokio::test]
async fn myc_nip46_provider_rejects_zero_timeout_policy() {
let error = RadrootsSdkMycNip46RequestPolicy::new(Duration::ZERO).expect_err("zero timeout");
@@ -392,6 +820,26 @@ async fn myc_nip46_provider_rejects_zero_timeout_policy() {
RadrootsSdkError::SignerUnavailable { ref mode, ref reason }
if mode == "myc_nip46" && reason.contains("timeout")
));
+
+ let target = RadrootsNostrConnectClientTarget::new(remote_keys().public_key(), Vec::new());
+ let transport = Arc::new(MockNip46Transport::new(remote_keys(), Vec::new()));
+ let constructor_error = match RadrootsSdkMycNip46Signer::new_with_request_policy(
+ client_keys(),
+ target,
+ USER_PUBLIC_KEY_HEX,
+ transport,
+ RadrootsSdkMycNip46RequestPolicy {
+ request_timeout: Duration::ZERO,
+ },
+ ) {
+ Ok(_) => panic!("expected zero timeout constructor error"),
+ Err(error) => error,
+ };
+ assert!(matches!(
+ constructor_error,
+ RadrootsSdkError::SignerUnavailable { ref mode, ref reason }
+ if mode == "myc_nip46" && reason.contains("timeout")
+ ));
}
#[tokio::test]
@@ -534,13 +982,32 @@ async fn myc_nip46_provider_rejects_returned_event_drift() {
#[tokio::test]
async fn sdk_builder_installs_configured_signer_provider() {
+ let empty_sdk = crate::RadrootsClient::builder()
+ .build()
+ .await
+ .expect("empty sdk");
+ let draft = frozen_draft();
+ let signer_actor = actor();
+ let error = empty_sdk
+ .sign_with_configured_signer(RadrootsSdkSignRequest::new(
+ "farm.publish",
+ &signer_actor,
+ &draft,
+ ))
+ .await
+ .expect_err("missing configured signer");
+ assert!(matches!(
+ error,
+ RadrootsSdkError::SignerUnavailable { ref mode, ref reason }
+ if mode == "configured" && reason.contains("no SDK signer provider")
+ ));
+
let signer = RadrootsSdkLocalKeySigner::new(user_keys()).expect("signer");
let sdk = crate::RadrootsClient::builder()
.signer_provider(RadrootsSdkSignerProvider::LocalKey(signer))
.build()
.await
.expect("sdk");
- let draft = frozen_draft();
assert!(sdk.configured_signer().is_some());
assert!(matches!(
@@ -550,9 +1017,12 @@ async fn sdk_builder_installs_configured_signer_provider() {
..
})
));
- let actor = actor();
let receipt = sdk
- .sign_with_configured_signer(RadrootsSdkSignRequest::new("farm.publish", &actor, &draft))
+ .sign_with_configured_signer(RadrootsSdkSignRequest::new(
+ "farm.publish",
+ &signer_actor,
+ &draft,
+ ))
.await
.expect("receipt");
assert_eq!(receipt.signed_event_id, draft.expected_event_id);
diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs
@@ -1,21 +1,96 @@
use super::{
- PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt, PushOutboxRelayOutcomeKind,
- SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus, push_event_final_state,
- push_event_receipt, push_outbox_claim_token,
+ CLAIM_OWNER, PushOutboxEventReceipt, PushOutboxEventState, PushOutboxReceipt,
+ PushOutboxRelayOutcomeKind, SdkRelayAuthPolicy, SyncEventStoreStatus, SyncOutboxStatus,
+ push_event_final_state, push_event_receipt, push_outbox_claim_token,
+};
+#[cfg(feature = "radrootsd-proxy")]
+use super::{
+ complete_proxy_publish_attempt, proxy_delivery_policy, proxy_error_message,
+ proxy_outbox_idempotency_key, proxy_transport_error_receipt, push_proxy_claimed_outbox_event,
};
use crate::RadrootsSdkError;
+#[cfg(feature = "radrootsd-proxy")]
+use crate::adapters::radrootsd::{
+ RadrootsdError, RadrootsdProxyConfig, RadrootsdProxyPublishAdapter,
+};
+#[cfg(feature = "radrootsd-proxy")]
+use crate::workflow_runtime::{SdkWorkflowEnqueueRequest, enqueue_signed_workflow};
use futures::future::BoxFuture;
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_authority::{
+ RadrootsActorContext, RadrootsEventSigner, RadrootsSignerError, RadrootsSignerIdentity,
+};
use radroots_event_store::RadrootsEventStoreStatusSummary;
-use radroots_events::ids::RadrootsEventId;
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_events::contract::RadrootsActorRole;
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_events::draft::RadrootsSignedNostrEvent;
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_events::kinds::KIND_FARM;
+use radroots_events::{draft::RadrootsFrozenEventDraft, ids::RadrootsEventId};
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_events_codec::wire::{WireEventParts, to_frozen_draft};
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_nostr::prelude::{
+ RadrootsNostrKeys, RadrootsNostrSecretKey, radroots_nostr_sign_frozen_draft,
+};
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_outbox::RadrootsOutboxClaimedEvent;
use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary};
+#[cfg(feature = "radrootsd-proxy")]
+use radroots_publish_proxy_protocol::PublishDeliveryPolicy;
use radroots_relay_transport::{
RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt,
RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTransportError,
};
use std::collections::BTreeSet;
+#[cfg(feature = "radrootsd-proxy")]
+const PROXY_SIGNER_SECRET_KEY_HEX: &str =
+ "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5";
+#[cfg(feature = "radrootsd-proxy")]
+const PROXY_SIGNER_PUBLIC_KEY_HEX: &str =
+ "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df";
+
struct UnusedPublishAdapter;
+#[cfg(feature = "radrootsd-proxy")]
+struct ProxyFixtureSigner {
+ identity: RadrootsSignerIdentity,
+ keys: RadrootsNostrKeys,
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+impl ProxyFixtureSigner {
+ fn new() -> Self {
+ let secret_key =
+ RadrootsNostrSecretKey::from_hex(PROXY_SIGNER_SECRET_KEY_HEX).expect("secret key");
+ let keys = RadrootsNostrKeys::new(secret_key);
+ Self {
+ identity: RadrootsSignerIdentity::new(PROXY_SIGNER_PUBLIC_KEY_HEX).expect("identity"),
+ keys,
+ }
+ }
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+impl RadrootsEventSigner for ProxyFixtureSigner {
+ fn pubkey(&self) -> &radroots_events::ids::RadrootsPublicKey {
+ self.identity.pubkey()
+ }
+
+ fn sign_frozen_draft(
+ &self,
+ draft: &RadrootsFrozenEventDraft,
+ ) -> Result<RadrootsSignedNostrEvent, RadrootsSignerError> {
+ radroots_nostr_sign_frozen_draft(&self.keys, draft).map_err(|error| {
+ RadrootsSignerError::SigningFailed {
+ message: error.to_string(),
+ }
+ })
+ }
+}
+
impl RadrootsRelayPublishAdapter for UnusedPublishAdapter {
fn publish<'a>(
&'a self,
@@ -26,6 +101,69 @@ impl RadrootsRelayPublishAdapter for UnusedPublishAdapter {
}
}
+#[cfg(feature = "radrootsd-proxy")]
+fn proxy_actor() -> RadrootsActorContext {
+ RadrootsActorContext::test(PROXY_SIGNER_PUBLIC_KEY_HEX, [RadrootsActorRole::Farmer])
+ .expect("actor")
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+fn proxy_frozen_draft(d_tag: &str) -> RadrootsFrozenEventDraft {
+ to_frozen_draft(
+ WireEventParts {
+ kind: KIND_FARM,
+ content: "{}".to_owned(),
+ tags: vec![vec!["d".to_owned(), d_tag.to_owned()]],
+ },
+ "radroots.farm.profile.v1",
+ PROXY_SIGNER_PUBLIC_KEY_HEX,
+ 1_700_000_000,
+ )
+ .expect("frozen draft")
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+async fn claimed_proxy_event(d_tag: &str) -> (crate::RadrootsClient, RadrootsOutboxClaimedEvent) {
+ let sdk = crate::RadrootsClient::builder()
+ .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds(
+ 1_700_000_000,
+ ))
+ .build()
+ .await
+ .expect("sdk");
+ let actor = proxy_actor();
+ let draft = proxy_frozen_draft(d_tag);
+ enqueue_signed_workflow(
+ &sdk,
+ SdkWorkflowEnqueueRequest {
+ operation_kind: "sync.proxy.unit.v1",
+ actor: &actor,
+ frozen_draft: &draft,
+ target_relays: crate::SdkRelayTargetPolicy::try_explicit(
+ ["wss://relay.example.com"],
+ crate::SdkRelayUrlPolicy::Public,
+ )
+ .expect("target relays"),
+ idempotency_key: None,
+ },
+ &ProxyFixtureSigner::new(),
+ )
+ .await
+ .expect("enqueue signed workflow");
+ let claimed = sdk
+ ._outbox
+ .claim_next_ready_signed_event(
+ CLAIM_OWNER,
+ "proxy-unit-claim",
+ 1_700_000_060_000,
+ 1_700_000_000_000,
+ )
+ .await
+ .expect("claim")
+ .expect("claimed event");
+ (sdk, claimed)
+}
+
#[test]
fn push_outbox_claim_tokens_are_unique_under_immediate_generation() {
let mut tokens = BTreeSet::new();
@@ -338,6 +476,194 @@ async fn sync_runtime_reports_clock_errors_before_store_or_relay_work() {
));
}
+#[cfg(feature = "radrootsd-proxy")]
+#[tokio::test]
+async fn proxy_push_empty_queue_and_private_helpers_are_deterministic() {
+ let sdk = crate::RadrootsClient::builder().build().await.expect("sdk");
+ let adapter =
+ RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc"));
+
+ let receipt = sdk
+ .sync()
+ .push_outbox_with_proxy_adapter(&adapter, super::PushOutboxRequest::new())
+ .await
+ .expect("empty proxy push");
+
+ assert_eq!(receipt.attempted_events, 0);
+ assert_eq!(proxy_delivery_policy(0), PublishDeliveryPolicy::Any);
+ assert_eq!(proxy_delivery_policy(2), PublishDeliveryPolicy::All);
+ assert_eq!(
+ proxy_outbox_idempotency_key(7, 3, "event-id"),
+ "radroots-sdk-outbox-7-3-event-id"
+ );
+
+ let proxy_receipt = proxy_transport_error_receipt("a".repeat(64));
+ assert_eq!(proxy_receipt.attempted_count, 1);
+ assert_eq!(proxy_receipt.retryable_count, 1);
+ assert_eq!(proxy_receipt.quorum, 1);
+ assert!(!proxy_receipt.quorum_met);
+ assert!(proxy_receipt.relays.is_empty());
+ assert_eq!(
+ proxy_error_message(&RadrootsdError::Http("connection refused".to_owned())),
+ "radrootsd proxy publish failed: connection refused"
+ );
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+#[tokio::test]
+async fn proxy_push_entrypoints_report_request_clock_and_claim_errors() {
+ let adapter =
+ RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc"));
+ let sdk = crate::RadrootsClient::builder().build().await.expect("sdk");
+ assert!(matches!(
+ sdk.sync()
+ .push_outbox_with_proxy_adapter(&adapter, super::PushOutboxRequest::new().with_limit(0))
+ .await,
+ Err(RadrootsSdkError::InvalidRequest { .. })
+ ));
+
+ let clock_sdk = crate::RadrootsClient::builder()
+ .clock(crate::RadrootsSdkClock::BeforeUnixEpoch)
+ .build()
+ .await
+ .expect("clock sdk");
+ assert!(matches!(
+ clock_sdk
+ .sync()
+ .push_outbox_with_proxy_adapter(&adapter, super::PushOutboxRequest::new())
+ .await,
+ Err(RadrootsSdkError::ClockBeforeUnixEpoch)
+ ));
+
+ let closed_outbox_sdk = crate::RadrootsClient::builder()
+ .build()
+ .await
+ .expect("closed sdk");
+ closed_outbox_sdk._outbox.pool().close().await;
+ assert!(matches!(
+ closed_outbox_sdk
+ .sync()
+ .push_outbox_with_proxy_adapter(&adapter, super::PushOutboxRequest::new())
+ .await,
+ Err(RadrootsSdkError::Outbox { .. })
+ ));
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+#[tokio::test]
+async fn proxy_push_reports_missing_signed_claim_before_daemon_publish() {
+ let sdk = crate::RadrootsClient::builder().build().await.expect("sdk");
+ let sync = sdk.sync();
+ let adapter =
+ RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc"));
+ let claimed = RadrootsOutboxClaimedEvent {
+ outbox_event_id: 41,
+ operation_id: 42,
+ expected_event_id: "b".repeat(64),
+ attempt_count: 3,
+ state: RadrootsOutboxEventState::Signed,
+ claim_token: "claim-token".to_owned(),
+ draft: RadrootsFrozenEventDraft {
+ contract_id: "radroots.test".to_owned(),
+ contract_registry_version: 1,
+ kind: 1,
+ created_at: 1_700_000_000,
+ tags: Vec::new(),
+ content: "{}".to_owned(),
+ expected_pubkey: "a".repeat(64),
+ expected_event_id: "b".repeat(64),
+ },
+ signed_event: None,
+ target_relays: vec!["wss://relay.example.com".to_owned()],
+ };
+
+ assert!(matches!(
+ push_proxy_claimed_outbox_event(&sync, &adapter, &claimed, 60_000, 1_700_000_000_000)
+ .await,
+ Err(RadrootsSdkError::RelayTransport { message })
+ if message.contains("Outbox claim 41 does not contain a signed event")
+ ));
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+#[tokio::test]
+async fn proxy_claim_publish_marks_retryable_transport_errors() {
+ let (sdk, claimed) = claimed_proxy_event("proxy-transport-error").await;
+ let sync = sdk.sync();
+ let adapter =
+ RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new("http://127.0.0.1:9/rpc"));
+ let receipt =
+ push_proxy_claimed_outbox_event(&sync, &adapter, &claimed, 60_000, 1_700_000_000_000)
+ .await
+ .expect("transport error receipt");
+
+ assert_eq!(receipt.retryable_count, 1);
+ let stored = sdk
+ ._outbox
+ .get_event(claimed.outbox_event_id)
+ .await
+ .expect("stored")
+ .expect("stored");
+ assert_eq!(stored.state, RadrootsOutboxEventState::PublishRetryable);
+ assert!(stored.claim_token.is_none());
+}
+
+#[cfg(feature = "radrootsd-proxy")]
+#[tokio::test]
+async fn proxy_completion_updates_outbox_for_success_retryable_and_terminal_receipts() {
+ let cases = [
+ ("proxy-complete-success", PushOutboxEventState::Published, {
+ let mut receipt = relay_publish_receipt("a".repeat(64).as_str());
+ receipt.quorum_met = true;
+ receipt.quorum = 1;
+ receipt.accepted_count = 1;
+ receipt
+ }),
+ (
+ "proxy-complete-retryable",
+ PushOutboxEventState::PublishRetryable,
+ {
+ let mut receipt = relay_publish_receipt("b".repeat(64).as_str());
+ receipt.retryable_count = 1;
+ receipt.quorum = 1;
+ receipt
+ },
+ ),
+ (
+ "proxy-complete-terminal",
+ PushOutboxEventState::FailedTerminal,
+ {
+ let mut receipt = relay_publish_receipt("c".repeat(64).as_str());
+ receipt.terminal_count = 1;
+ receipt.quorum = 1;
+ receipt
+ },
+ ),
+ ];
+
+ for (d_tag, expected_state, mut publish) in cases {
+ let (sdk, claimed) = claimed_proxy_event(d_tag).await;
+ publish.event_id = claimed
+ .signed_event
+ .as_ref()
+ .expect("signed event")
+ .id
+ .clone();
+ let sync = sdk.sync();
+ complete_proxy_publish_attempt(&sync, &claimed, &publish, 60_000, 1_700_000_000_000)
+ .await
+ .expect("complete proxy attempt");
+ let stored = sdk
+ ._outbox
+ .get_event(claimed.outbox_event_id)
+ .await
+ .expect("stored")
+ .expect("stored");
+ assert_eq!(PushOutboxEventState::from(stored.state), expected_state);
+ assert!(stored.claim_token.is_none());
+ }
+}
+
fn relay_publish_receipt(event_id: &str) -> RadrootsRelayPublishReceipt {
RadrootsRelayPublishReceipt {
event_id: event_id.to_owned(),