lib

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

commit 9dbd53dc24b8aff3d421a6cac4c5cbec0616d7da
parent 8f8435669750f272889501b0fef0c173c32d3860
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:
Mcrates/sdk/src/market_runtime.rs | 4++++
Mcrates/sdk/src/private_store.rs | 4++++
Mcrates/sdk/tests/geonames.rs | 158++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcrates/sdk/tests/orders_runtime.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/sync_runtime.rs | 196++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 295++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/error_tests.rs | 202++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/farms_runtime_tests.rs | 348+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/listings_runtime_tests.rs | 44++++++++++++++++++++++++++++++++++++++++++++
Acrates/sdk/tests/unit/market_runtime_tests.rs | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/orders_runtime_tests.rs | 688+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acrates/sdk/tests/unit/private_store_tests.rs | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/sdk/tests/unit/runtime_tests.rs | 263++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/signer_provider_tests.rs | 482++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 334++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
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(),