sdk

Radroots SDK and bindings
git clone https://radroots.dev/git/sdk.git
Log | Files | Refs | README

commit 627b6823c9f3f44f0e1bd256ba019c92a046e22d
parent 544d5c40006e8e3b27e1ca1e0a13543237abf4b3
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Jul 2026 01:43:34 +0000

sdk: harden proxy satisfaction metadata

- reject delivered-class radrootsd proxy satisfaction before local ingest or daemon publish
- preserve scoped target metadata through explicit proxy requests and receipts
- correlate proxy completion outcomes by target scope for duplicate relay endpoints
- cover scoped metadata and warning hygiene with SDK tests and source guards

Diffstat:
Mcrates/sdk/src/sync_runtime.rs | 34++++++++++++++++++++++++++++++----
Mcrates/sdk/tests/source_boundary.rs | 22++++++++++++++++++++++
Mcrates/sdk/tests/unit/adapters_radrootsd_tests.rs | 78+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcrates/sdk/tests/unit/sync_runtime_tests.rs | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
4 files changed, 375 insertions(+), 18 deletions(-)

diff --git a/crates/sdk/src/sync_runtime.rs b/crates/sdk/src/sync_runtime.rs @@ -31,14 +31,14 @@ use radroots_trade::projection::{ RadrootsProjectionRefreshReceipt, RadrootsProjectionRefreshRequest, refresh_product_projections, }; -#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] -use radroots_transport::RadrootsTransportSatisfactionPolicy; #[cfg(feature = "runtime")] use radroots_transport::{ RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportOutcomeKind, RadrootsTransportStatus, RadrootsTransportTarget, }; +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +use radroots_transport::{RadrootsTransportSatisfactionClass, RadrootsTransportSatisfactionPolicy}; #[cfg(all(feature = "runtime", feature = "transport-nostr-runtime"))] use radroots_transport_nostr::RadrootsNostrClientPublishAdapter; #[cfg(feature = "runtime")] @@ -1175,6 +1175,7 @@ fn proxy_delivery_policy_from_remaining( required_remaining: usize, satisfaction_policy: &RadrootsTransportSatisfactionPolicy, ) -> Result<TransportPublishDeliveryPolicy, RadrootsSdkError> { + reject_delivered_proxy_satisfaction(satisfaction_policy)?; if ready_target_count == 0 || required_remaining == 0 { return Ok(TransportPublishDeliveryPolicy::Any); } @@ -1200,6 +1201,21 @@ fn proxy_delivery_policy_from_remaining( } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] +fn reject_delivered_proxy_satisfaction( + satisfaction_policy: &RadrootsTransportSatisfactionPolicy, +) -> Result<(), RadrootsSdkError> { + if satisfaction_policy.target_satisfaction_class() + == Some(RadrootsTransportSatisfactionClass::Delivered) + { + return Err(RadrootsSdkError::InvalidRequest { + message: "radrootsd proxy publish only supports accepted-class satisfaction policies" + .to_owned(), + }); + } + Ok(()) +} + +#[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] fn proxy_outbox_idempotency_key( outbox_event_id: i64, attempt_count: i64, @@ -1344,6 +1360,14 @@ fn transport_publish_target_from_outbox_target( Ok(TransportPublishTarget { transport_kind: target.transport_kind.canonical_label(), endpoint_uri: target.endpoint_uri.as_str().to_owned(), + target_scope: target + .target_scope + .as_ref() + .map(|scope| scope.as_str().to_owned()), + target_label: target + .target_label + .as_ref() + .map(|label| label.as_str().to_owned()), preview_behavior: None, }) } @@ -1355,6 +1379,8 @@ fn proxy_target_matches_outcome( ) -> bool { target.transport_kind.canonical_label() == outcome.transport_kind && target.endpoint_uri.as_str() == outcome.endpoint_uri + && target.target_scope.as_ref().map(|scope| scope.as_str()) + == outcome.target_scope.as_deref() } #[cfg(all(feature = "runtime", feature = "radrootsd-proxy"))] @@ -1614,8 +1640,8 @@ fn push_proxy_target_receipt(outcome: TransportPublishTargetOutcome) -> PushOutb PushOutboxTargetReceipt { transport_kind: outcome.transport_kind, endpoint_uri: outcome.endpoint_uri, - target_scope: None, - target_label: None, + target_scope: outcome.target_scope, + target_label: outcome.target_label, outcome_kind: push_proxy_target_outcome_kind(outcome.outcome_kind), transport_outcome_kind: Some(push_proxy_transport_outcome_kind(outcome.outcome_kind)), attempted: outcome.attempted, diff --git a/crates/sdk/tests/source_boundary.rs b/crates/sdk/tests/source_boundary.rs @@ -1477,6 +1477,10 @@ fn sdk_proxy_surfaces_reject_removed_daemon_publish_proxy_identifiers() { "assert!(!stored_before.event_store_ingested)", "assert!(!stored.event_store_ingested)", "with_timeout(Duration::from_millis(50))", + "proxy_delivery_policy_rejects_delivered_satisfaction_before_daemon_publish", + "proxy_outbox_target_conversion_preserves_nostr_scope_and_label", + "proxy_completion_matches_duplicate_endpoint_targets_by_scope", + "push_proxy_event_receipt_preserves_daemon_target_metadata", "proxy_completion_rejects_duplicate_daemon_outcome_before_local_mutation", ] { assert!( @@ -1509,6 +1513,12 @@ fn sdk_proxy_surfaces_reject_removed_daemon_publish_proxy_identifiers() { "PushOutboxEventState::PreviewUnavailable", "PushOutboxTargetOutcomeKind::DeferredUntilImplemented", "PushOutboxTargetOutcomeKind::PreviewUnavailable", + "reject_delivered_proxy_satisfaction", + "RadrootsTransportSatisfactionClass::Delivered", + "target.target_scope.as_ref()", + "outcome.target_scope.as_deref()", + "target_scope: outcome.target_scope", + "target_label: outcome.target_label", ] { assert!( sync_runtime_source.contains(required), @@ -1531,6 +1541,18 @@ fn sdk_proxy_surfaces_reject_removed_daemon_publish_proxy_identifiers() { "push_proxy_event_receipt must not use production panic path `{forbidden}`" ); } + + let proxy_target_receipt_source = source_between( + sync_runtime_source.as_str(), + "fn push_proxy_target_receipt", + "fn push_proxy_target_outcome_kind", + ); + for forbidden in ["target_scope: None", "target_label: None"] { + assert!( + !proxy_target_receipt_source.contains(forbidden), + "push_proxy_target_receipt must not hard-code daemon metadata field `{forbidden}`" + ); + } } #[test] diff --git a/crates/sdk/tests/unit/adapters_radrootsd_tests.rs b/crates/sdk/tests/unit/adapters_radrootsd_tests.rs @@ -168,6 +168,8 @@ fn job(outcome_kind: TransportPublishOutcomeKind) -> TransportPublishJobView { targets: vec![TransportPublishTargetOutcome { transport_kind: "nostr".to_owned(), endpoint_uri: "wss://relay.example.com".to_owned(), + target_scope: None, + target_label: None, source: TransportPublishTargetSource::Request, attempted: true, outcome_kind, @@ -186,6 +188,8 @@ fn explicit_nostr_job( .map(|endpoint| TransportPublishTargetOutcome { transport_kind: "nostr".to_owned(), endpoint_uri: endpoint.clone(), + target_scope: None, + target_label: None, source: TransportPublishTargetSource::Request, attempted: true, outcome_kind: TransportPublishOutcomeKind::Accepted, @@ -244,6 +248,8 @@ fn reticulum_deferred_job() -> TransportPublishJobView { targets: vec![TransportPublishTargetOutcome { transport_kind: "reticulum".to_owned(), endpoint_uri: RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI.to_owned(), + target_scope: None, + target_label: None, source: TransportPublishTargetSource::ReticulumPreview, attempted: false, outcome_kind: TransportPublishOutcomeKind::DeferredUntilImplemented, @@ -253,13 +259,6 @@ fn reticulum_deferred_job() -> TransportPublishJobView { } } -fn explicit_nostr_response_json( - endpoints: Vec<String>, - delivery_policy: TransportPublishDeliveryPolicy, -) -> String { - publish_response_json_for_job(explicit_nostr_job(endpoints, delivery_policy)) -} - fn publish_response_json_for_job(job: TransportPublishJobView) -> String { serde_json::json!({ "jsonrpc": "2.0", @@ -473,10 +472,18 @@ async fn publish_event_posts_transport_publish_jsonrpc() { #[tokio::test] async fn publish_signed_event_posts_typed_proxy_request() { - let response_json = explicit_nostr_response_json( + let mut response_job = explicit_nostr_job( vec!["wss://relay.example.com".to_owned()], TransportPublishDeliveryPolicy::All, ); + response_job.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com") + .with_scope("farm.local") + .with_label("Farm relay"), + ]); + response_job.targets[0].target_scope = Some("farm.local".to_owned()); + response_job.targets[0].target_label = Some("Farm relay".to_owned()); + let response_json = publish_response_json_for_job(response_job); let (endpoint, handle) = spawn_http_server("200 OK", response_json.as_str()); let adapter = RadrootsdProxyPublishAdapter::new( RadrootsdProxyConfig::new(endpoint) @@ -488,7 +495,9 @@ async fn publish_signed_event_posts_typed_proxy_request() { .publish_signed_event(RadrootsdProxyPublishRequest { signed_event: signed_event(), target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ - TransportPublishTarget::nostr("wss://relay.example.com"), + TransportPublishTarget::nostr("wss://relay.example.com") + .with_scope("farm.local") + .with_label("Farm relay"), ]), delivery_policy: TransportPublishDeliveryPolicy::All, idempotency_key: Some("idem-typed".to_owned()), @@ -498,6 +507,14 @@ async fn publish_signed_event_posts_typed_proxy_request() { .expect("typed publish"); assert!(receipt.job.delivery_satisfied); + assert_eq!( + receipt.job.targets[0].target_scope.as_deref(), + Some("farm.local") + ); + assert_eq!( + receipt.job.targets[0].target_label.as_deref(), + Some("Farm relay") + ); let recorded = handle.join().expect("server thread"); assert!( recorded @@ -512,6 +529,14 @@ async fn publish_signed_event_posts_typed_proxy_request() { body["params"]["target_policy"]["targets"][0]["endpoint_uri"], "wss://relay.example.com" ); + assert_eq!( + body["params"]["target_policy"]["targets"][0]["target_scope"], + "farm.local" + ); + assert_eq!( + body["params"]["target_policy"]["targets"][0]["target_label"], + "Farm relay" + ); assert_eq!(body["params"]["idempotency_key"], "idem-typed"); assert_eq!(body["params"]["timeout_ms"], 7_000); } @@ -740,6 +765,37 @@ async fn publish_signed_event_rejects_mismatched_explicit_target_outcomes() { } #[tokio::test] +async fn publish_signed_event_rejects_mismatched_scoped_explicit_target_outcomes() { + let mut response_job = explicit_nostr_job( + vec!["wss://relay.example.com".to_owned()], + TransportPublishDeliveryPolicy::Any, + ); + response_job.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com").with_scope("farm.local"), + ]); + response_job.targets[0].target_scope = Some("farm.remote".to_owned()); + let response_json = publish_response_json_for_job(response_job); + let (endpoint, _handle) = spawn_http_server("200 OK", response_json.as_str()); + let adapter = RadrootsdProxyPublishAdapter::new(RadrootsdProxyConfig::new(endpoint)); + + let error = adapter + .publish_signed_event(RadrootsdProxyPublishRequest { + signed_event: signed_event(), + target_policy: TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com").with_scope("farm.local"), + ]), + delivery_policy: TransportPublishDeliveryPolicy::Any, + idempotency_key: Some("idem-scoped-explicit-outcome-mismatch".to_owned()), + timeout_ms: None, + }) + .await + .expect_err("scoped explicit target outcome mismatch"); + + assert!(matches!(error, RadrootsdError::MalformedResponse(_))); + assert_message(error, "explicit target policy"); +} + +#[tokio::test] async fn publish_event_http_errors_omit_body_and_token_material() { let body = "{\"error\":\"token-secret content carrots\"}"; let (endpoint, _handle) = spawn_http_server("503 Service Unavailable", body); @@ -814,6 +870,8 @@ async fn publish_signed_event_rejects_invalid_protocol_requests_before_http() { TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget { transport_kind: "nostr".to_owned(), endpoint_uri: "wss://relay.example.com".to_owned(), + target_scope: None, + target_label: None, preview_behavior: Some(TransportPublishPreviewBehavior::RejectDeliveryAttempts), }]); let mut explicit_proxy_target = base.clone(); @@ -821,6 +879,8 @@ async fn publish_signed_event_rejects_invalid_protocol_requests_before_http() { TransportPublishTargetPolicy::explicit_targets(vec![TransportPublishTarget { transport_kind: "proxy".to_owned(), endpoint_uri: "radrootsd-proxy:publish".to_owned(), + target_scope: None, + target_label: None, preview_behavior: None, }]); let proxy_error = adapter diff --git a/crates/sdk/tests/unit/sync_runtime_tests.rs b/crates/sdk/tests/unit/sync_runtime_tests.rs @@ -45,8 +45,8 @@ use radroots_outbox::{ use radroots_outbox::{RadrootsOutboxEventState, RadrootsOutboxStatusSummary}; #[cfg(feature = "radrootsd-proxy")] use radroots_transport::{ - RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportKind, - RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, + RADROOTS_RETICULUM_PREVIEW_ENDPOINT_URI, RadrootsTransportKind, RadrootsTransportMeshScopeId, + RadrootsTransportSatisfactionPolicy, RadrootsTransportTarget, RadrootsTransportTargetLabel, }; use radroots_transport_nostr::{ RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishReceipt, @@ -55,8 +55,8 @@ use radroots_transport_nostr::{ #[cfg(feature = "radrootsd-proxy")] use radroots_transport_publish_protocol::{ NostrPublishTargetSourcePolicy, TransportPublishDeliveryPolicy, TransportPublishJobStatus, - TransportPublishJobView, TransportPublishOutcomeKind, TransportPublishTargetOutcome, - TransportPublishTargetPolicy, TransportPublishTargetSource, + TransportPublishJobView, TransportPublishOutcomeKind, TransportPublishTarget, + TransportPublishTargetOutcome, TransportPublishTargetPolicy, TransportPublishTargetSource, }; use std::collections::BTreeSet; #[cfg(feature = "radrootsd-proxy")] @@ -191,6 +191,20 @@ async fn claimed_uningested_proxy_event( d_tag: &str, proxy_endpoint: &str, ) -> (crate::RadrootsClient, RadrootsOutboxClaimedEvent) { + claimed_uningested_proxy_event_with_satisfaction( + d_tag, + proxy_endpoint, + RadrootsTransportSatisfactionPolicy::all_accepted(), + ) + .await +} + +#[cfg(feature = "radrootsd-proxy")] +async fn claimed_uningested_proxy_event_with_satisfaction( + d_tag: &str, + proxy_endpoint: &str, + satisfaction_policy: RadrootsTransportSatisfactionPolicy, +) -> (crate::RadrootsClient, RadrootsOutboxClaimedEvent) { let sdk = crate::RadrootsClient::builder() .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( 1_700_000_000, @@ -210,7 +224,7 @@ async fn claimed_uningested_proxy_event( RadrootsOutboxDeliveryPlanInput::new( "proxy", 1, - RadrootsTransportSatisfactionPolicy::all_accepted(), + satisfaction_policy, vec![proxy_target], ), 1_700_000_000_000, @@ -277,6 +291,29 @@ fn assert_no_transport_publish_request(listener: &TcpListener) { } #[cfg(feature = "radrootsd-proxy")] +fn delivery_target_record( + delivery_target_id: i64, + delivery_plan_id: i64, + target: &RadrootsTransportTarget, +) -> RadrootsOutboxDeliveryTargetRecord { + RadrootsOutboxDeliveryTargetRecord { + delivery_target_id, + delivery_plan_id, + transport_kind: target.kind.clone(), + endpoint_uri: target.uri.clone(), + target_scope: target.scope.clone(), + target_label: target.label.clone(), + endpoint_fingerprint: target.fingerprint.clone(), + status: RadrootsOutboxDeliveryTargetStatus::Pending, + last_outcome_kind: None, + attempt_count: 0, + last_attempt_at_ms: None, + completed_at_ms: None, + last_error: None, + } +} + +#[cfg(feature = "radrootsd-proxy")] fn proxy_job(event_id: &str, outcome_kind: TransportPublishOutcomeKind) -> TransportPublishJobView { let delivery_satisfied = outcome_kind.counts_toward_satisfaction(); let retryable = outcome_kind.is_retryable(); @@ -315,6 +352,8 @@ fn proxy_job(event_id: &str, outcome_kind: TransportPublishOutcomeKind) -> Trans targets: vec![TransportPublishTargetOutcome { transport_kind: "nostr".to_owned(), endpoint_uri: "wss://relay.example.com".to_owned(), + target_scope: None, + target_label: None, source: TransportPublishTargetSource::Request, attempted: true, outcome_kind, @@ -735,6 +774,53 @@ async fn proxy_push_empty_queue_and_private_helpers_are_deterministic() { } #[cfg(feature = "radrootsd-proxy")] +#[tokio::test] +async fn proxy_delivery_policy_rejects_delivered_satisfaction_before_daemon_publish() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind proxy listener"); + let endpoint = format!("http://{}/rpc", listener.local_addr().expect("addr")); + let (sdk, claimed) = claimed_uningested_proxy_event_with_satisfaction( + "proxy-delivered-rejected", + endpoint.as_str(), + RadrootsTransportSatisfactionPolicy::all_delivered(), + ) + .await; + let sync = sdk.sync(); + let adapter = RadrootsdProxyPublishAdapter::new( + RadrootsdProxyConfig::new(endpoint).with_timeout(Duration::from_millis(50)), + ); + + let error = + push_proxy_claimed_outbox_event(&sync, &adapter, &claimed, 60_000, 1_700_000_000_000) + .await + .expect_err("delivered-class proxy satisfaction rejected"); + assert_no_transport_publish_request(&listener); + + assert!(matches!( + error, + RadrootsSdkError::InvalidRequest { message } + if message.contains("radrootsd proxy publish") + && message.contains("accepted-class satisfaction") + )); + let stored = sdk + ._outbox + .get_event(claimed.outbox_event_id) + .await + .expect("stored") + .expect("stored"); + assert_eq!(stored.state, RadrootsOutboxEventState::FailedTerminal); + assert!(stored.claim_token.is_none()); + assert!(!stored.event_store_ingested); + assert_eq!(stored.event_store_ingested_at_ms, None); + assert!( + stored + .last_error + .as_deref() + .expect("last error") + .contains("accepted-class satisfaction") + ); +} + +#[cfg(feature = "radrootsd-proxy")] #[test] fn proxy_outbox_target_conversion_rejects_reticulum_targets_before_behavior_loss() { let target = RadrootsTransportTarget::new( @@ -772,6 +858,27 @@ fn proxy_outbox_target_conversion_rejects_reticulum_targets_before_behavior_loss #[cfg(feature = "radrootsd-proxy")] #[test] +fn proxy_outbox_target_conversion_preserves_nostr_scope_and_label() { + let target = RadrootsTransportTarget::new_with_metadata( + RadrootsTransportKind::Nostr, + "wss://relay.example.com", + Some(RadrootsTransportMeshScopeId::parse("farm.local").expect("scope")), + Some(RadrootsTransportTargetLabel::parse("Farm relay").expect("label")), + ) + .expect("scoped Nostr target"); + let record = delivery_target_record(1, 1, &target); + + let converted = transport_publish_target_from_outbox_target(&record).expect("converted target"); + + assert_eq!(converted.transport_kind, "nostr"); + assert_eq!(converted.endpoint_uri, "wss://relay.example.com"); + assert_eq!(converted.target_scope.as_deref(), Some("farm.local")); + assert_eq!(converted.target_label.as_deref(), Some("Farm relay")); + assert_eq!(converted.preview_behavior, None); +} + +#[cfg(feature = "radrootsd-proxy")] +#[test] fn proxy_outbox_target_conversion_rejects_proxy_targets_before_daemon_explicit_target() { let target = RadrootsTransportTarget::new(RadrootsTransportKind::Proxy, "http://127.0.0.1:8080/rpc") @@ -1262,6 +1369,125 @@ async fn proxy_completion_updates_outbox_for_success_retryable_and_terminal_rece #[cfg(feature = "radrootsd-proxy")] #[tokio::test] +async fn proxy_completion_matches_duplicate_endpoint_targets_by_scope() { + let sdk = crate::RadrootsClient::builder() + .fixed_clock(crate::RadrootsSdkTimestamp::from_unix_seconds( + 1_700_000_000, + )) + .build() + .await + .expect("sdk"); + let draft = proxy_frozen_draft("proxy-complete-scoped-targets"); + let signed_event = ProxyFixtureSigner::new() + .sign_frozen_draft(&draft) + .expect("signed event"); + let farm_a = RadrootsTransportTarget::new_with_metadata( + RadrootsTransportKind::Nostr, + "wss://relay.example.com", + Some(RadrootsTransportMeshScopeId::parse("farm.a").expect("farm a scope")), + Some(RadrootsTransportTargetLabel::parse("Farm A").expect("farm a label")), + ) + .expect("farm a target"); + let farm_b = RadrootsTransportTarget::new_with_metadata( + RadrootsTransportKind::Nostr, + "wss://relay.example.com", + Some(RadrootsTransportMeshScopeId::parse("farm.b").expect("farm b scope")), + Some(RadrootsTransportTargetLabel::parse("Farm B").expect("farm b label")), + ) + .expect("farm b target"); + let enqueue = sdk + ._outbox + .enqueue_signed_operation( + RadrootsOutboxSignedOperationInput::new( + "sync.proxy.unit.v1", + draft, + signed_event.clone(), + RadrootsOutboxDeliveryPlanInput::new( + "proxy.scoped", + 2, + RadrootsTransportSatisfactionPolicy::all_accepted(), + vec![farm_a, farm_b], + ), + true, + 1_700_000_000_000, + 1_700_000_000_000, + ) + .with_idempotency_key("proxy-complete-scoped-targets"), + ) + .await + .expect("scoped proxy event"); + let claimed = sdk + ._outbox + .claim_next_ready_signed_event( + CLAIM_OWNER, + "proxy-scoped-target-claim", + 1_700_000_060_000, + 1_700_000_000_000, + ) + .await + .expect("claim") + .expect("claim"); + assert_eq!(claimed.outbox_event_id, enqueue.outbox_event_id); + let mut publish = proxy_job( + signed_event.id.as_str(), + TransportPublishOutcomeKind::Accepted, + ); + publish.target_policy = TransportPublishTargetPolicy::explicit_targets(vec![ + TransportPublishTarget::nostr("wss://relay.example.com") + .with_scope("farm.a") + .with_label("Farm A"), + TransportPublishTarget::nostr("wss://relay.example.com") + .with_scope("farm.b") + .with_label("Farm B"), + ]); + publish.delivery_policy = TransportPublishDeliveryPolicy::All; + publish.delivery_satisfied = false; + publish.status = TransportPublishJobStatus::DeliveryUnsatisfiedRetryable; + publish.terminal = false; + publish.target_count = 2; + publish.acknowledged_count = 1; + publish.retryable_count = 1; + publish.terminal_count = 0; + publish.completed_at_ms = None; + publish.targets[0].target_scope = Some("farm.a".to_owned()); + publish.targets[0].target_label = Some("Farm A".to_owned()); + let mut farm_b_outcome = publish.targets[0].clone(); + farm_b_outcome.target_scope = Some("farm.b".to_owned()); + farm_b_outcome.target_label = Some("Farm B".to_owned()); + farm_b_outcome.outcome_kind = TransportPublishOutcomeKind::Timeout; + farm_b_outcome.message = Some("daemon timeout".to_owned()); + publish.targets.push(farm_b_outcome); + + let sync = sdk.sync(); + complete_proxy_publish_attempt(&sync, &claimed, &publish, 60_000, 1_700_000_000_000) + .await + .expect("complete scoped proxy attempt"); + let targets = sdk + ._outbox + .delivery_targets(claimed.outbox_event_id) + .await + .expect("targets"); + let farm_a_target = targets + .iter() + .find(|target| target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.a")) + .expect("farm a target"); + let farm_b_target = targets + .iter() + .find(|target| target.target_scope.as_ref().map(|scope| scope.as_str()) == Some("farm.b")) + .expect("farm b target"); + + assert_eq!( + farm_a_target.status, + RadrootsOutboxDeliveryTargetStatus::Accepted + ); + assert_eq!( + farm_b_target.status, + RadrootsOutboxDeliveryTargetStatus::FailedRetryable + ); +} + +#[cfg(feature = "radrootsd-proxy")] +#[tokio::test] async fn proxy_completion_rejects_duplicate_daemon_outcome_before_local_mutation() { let (sdk, claimed) = claimed_proxy_event("proxy-complete-duplicate-outcome").await; let mut publish = proxy_job( @@ -1300,6 +1526,29 @@ async fn proxy_completion_rejects_duplicate_daemon_outcome_before_local_mutation #[cfg(feature = "radrootsd-proxy")] #[test] +fn push_proxy_event_receipt_preserves_daemon_target_metadata() { + let mut publish = proxy_job( + "a".repeat(64).as_str(), + TransportPublishOutcomeKind::Accepted, + ); + publish.targets[0].target_scope = Some("farm.local".to_owned()); + publish.targets[0].target_label = Some("Farm relay".to_owned()); + + let receipt = push_proxy_event_receipt(1, publish).expect("receipt"); + + assert_eq!(receipt.targets.len(), 1); + assert_eq!( + receipt.targets[0].target_scope.as_deref(), + Some("farm.local") + ); + assert_eq!( + receipt.targets[0].target_label.as_deref(), + Some("Farm relay") + ); +} + +#[cfg(feature = "radrootsd-proxy")] +#[test] fn push_proxy_event_receipt_returns_typed_error_for_invalid_daemon_event_id() { let error = push_proxy_event_receipt( 1,