commit 1f7c1d99bb15de81dffb31bb2bd649cdd74104ee
parent e23ea523c84b190abb9b80ad2821e99b801ff3c2
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 07:27:28 +0000
cli: validate dry run preflight
Diffstat:
5 files changed, 128 insertions(+), 50 deletions(-)
diff --git a/src/operation_listing.rs b/src/operation_listing.rs
@@ -105,11 +105,9 @@ impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> {
&self,
request: OperationRequest<ListingUpdateRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
- if request.context.dry_run {
- return mutation_dry_run::<ListingUpdateResult>(&request, "update");
- }
let args = mutation_args(&request)?;
- let view = map_runtime(crate::runtime::listing::update(self.config, &args))?;
+ let config = mutation_config(self.config, &request);
+ let view = map_runtime(crate::runtime::listing::update(&config, &args))?;
serialized_operation_result::<ListingUpdateResult, _>(&view)
}
}
@@ -136,12 +134,12 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> {
&self,
request: OperationRequest<ListingPublishRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
- if request.context.dry_run {
- return mutation_dry_run::<ListingPublishResult>(&request, "publish");
+ if !request.context.dry_run {
+ require_approval(&request)?;
}
- require_approval(&request)?;
let args = mutation_args(&request)?;
- let view = crate::runtime::listing::publish(self.config, &args)
+ let config = mutation_config(self.config, &request);
+ let view = crate::runtime::listing::publish(&config, &args)
.map_err(|error| publish_runtime_error(request.operation_id(), error))?;
mutation_result::<ListingPublishResult>(request.operation_id(), &view)
}
@@ -154,16 +152,27 @@ impl OperationService<ListingArchiveRequest> for ListingOperationService<'_> {
&self,
request: OperationRequest<ListingArchiveRequest>,
) -> Result<OperationResult<Self::Result>, OperationAdapterError> {
- if request.context.dry_run {
- return mutation_dry_run::<ListingArchiveResult>(&request, "archive");
+ if !request.context.dry_run {
+ require_approval(&request)?;
}
- require_approval(&request)?;
let args = mutation_args(&request)?;
- let view = map_runtime(crate::runtime::listing::archive(self.config, &args))?;
+ let config = mutation_config(self.config, &request);
+ let view = map_runtime(crate::runtime::listing::archive(&config, &args))?;
mutation_result::<ListingArchiveResult>(request.operation_id(), &view)
}
}
+fn mutation_config<P>(config: &RuntimeConfig, request: &OperationRequest<P>) -> RuntimeConfig
+where
+ P: OperationRequestPayload,
+{
+ let mut config = config.clone();
+ if request.context.dry_run {
+ config.output.dry_run = true;
+ }
+ config
+}
+
fn mutation_args<P>(
request: &OperationRequest<P>,
) -> Result<ListingMutationArgs, OperationAdapterError>
@@ -183,22 +192,6 @@ where
})
}
-fn mutation_dry_run<R>(
- request: &OperationRequest<impl OperationRequestPayload + OperationRequestData>,
- action: &str,
-) -> Result<OperationResult<R>, OperationAdapterError>
-where
- R: OperationResultData,
-{
- json_operation_result::<R>(json!({
- "state": "dry_run",
- "action": action,
- "file": optional_path(request, "file").map(|path| path.display().to_string()),
- "idempotency_key": request.context.idempotency_key,
- "signer_session_id": string_input(request, "signer_session_id"),
- }))
-}
-
fn require_approval<P>(request: &OperationRequest<P>) -> Result<(), OperationAdapterError>
where
P: OperationRequestPayload + OperationRequestData,
@@ -441,13 +434,8 @@ mod tests {
ListingArchiveRequest::from_data(data(&[("file", "listing.toml")])),
)
.expect("listing archive request");
- let archive_envelope = service
- .execute(archive)
- .expect("archive dry run")
- .to_envelope(context.envelope_context("req_listing_archive"))
- .expect("archive envelope");
- assert_eq!(archive_envelope.operation_id, "listing.archive");
- assert_eq!(archive_envelope.result["state"], "dry_run");
+ let archive_error = service.execute(archive).expect_err("archive preflight");
+ assert!(!format!("{archive_error}").contains("approval_token"));
}
fn sample_config(root: &Path) -> RuntimeConfig {
diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs
@@ -1085,6 +1085,13 @@ fn mutate(
let (event_preview, listing_addr) = build_listing_event_preview(&canonical)?;
+ if config.output.dry_run
+ && matches!(operation, ListingMutationOperation::Publish)
+ && matches!(config.signer.backend, SignerBackend::Local)
+ {
+ validate_local_listing_signer(config, &canonical)?;
+ }
+
if config.output.dry_run {
return Ok(ListingMutationView {
state: "dry_run".to_owned(),
@@ -1788,6 +1795,27 @@ fn sign_listing_event(
config: &RuntimeConfig,
canonical: &CanonicalListingDraft,
) -> Result<radroots_nostr::prelude::RadrootsNostrEvent, RuntimeError> {
+ let signing = resolve_listing_signing_identity(config, canonical)?;
+ let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING)
+ .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?;
+ let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
+ .map_err(|error| RuntimeError::Config(format!("build local listing event: {error}")))?
+ .sign_with_keys(signing.identity.keys())
+ .map_err(|error| RuntimeError::Config(format!("sign local listing event: {error}")))?;
+ Ok(event)
+}
+
+fn validate_local_listing_signer(
+ config: &RuntimeConfig,
+ canonical: &CanonicalListingDraft,
+) -> Result<(), RuntimeError> {
+ resolve_listing_signing_identity(config, canonical).map(|_| ())
+}
+
+fn resolve_listing_signing_identity(
+ config: &RuntimeConfig,
+ canonical: &CanonicalListingDraft,
+) -> Result<accounts::AccountSigningIdentity, RuntimeError> {
let signing = accounts::resolve_local_signing_identity(config)?;
let account_pubkey = signing
.account
@@ -1801,13 +1829,7 @@ fn sign_listing_event(
canonical.seller_pubkey
)));
}
- let parts = to_wire_parts_with_kind(&canonical.listing, KIND_LISTING)
- .map_err(|error| RuntimeError::Config(format!("invalid listing contract: {error}")))?;
- let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags)
- .map_err(|error| RuntimeError::Config(format!("build local listing event: {error}")))?
- .sign_with_keys(signing.identity.keys())
- .map_err(|error| RuntimeError::Config(format!("sign local listing event: {error}")))?;
- Ok(event)
+ Ok(signing)
}
fn signed_listing_event_view(
diff --git a/src/runtime/order.rs b/src/runtime/order.rs
@@ -350,7 +350,7 @@ pub fn submit(
buyer_account_id: None,
buyer_pubkey: None,
seller_pubkey: None,
- dry_run: false,
+ dry_run: config.output.dry_run,
deduplicated: false,
idempotency_key: args.idempotency_key.clone(),
signer_mode: None,
@@ -379,7 +379,7 @@ pub fn submit(
buyer_account_id: None,
buyer_pubkey: None,
seller_pubkey: None,
- dry_run: false,
+ dry_run: config.output.dry_run,
deduplicated: false,
idempotency_key: args.idempotency_key.clone(),
signer_mode: None,
@@ -409,7 +409,7 @@ pub fn submit(
buyer_account_id: loaded.document.buyer_account_id.clone(),
buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()),
- dry_run: false,
+ dry_run: config.output.dry_run,
deduplicated: false,
idempotency_key: args.idempotency_key.clone(),
signer_mode: job.signer_mode.clone(),
@@ -439,7 +439,7 @@ pub fn submit(
buyer_account_id: loaded.document.buyer_account_id.clone(),
buyer_pubkey: non_empty_string(loaded.document.order.buyer_pubkey.clone()),
seller_pubkey: non_empty_string(loaded.document.order.seller_pubkey.clone()),
- dry_run: false,
+ dry_run: config.output.dry_run,
deduplicated: false,
idempotency_key: args.idempotency_key.clone(),
signer_mode: None,
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -289,6 +289,27 @@ fn local_listing_publish_fails_without_local_account_authority() {
}
#[test]
+fn local_listing_publish_dry_run_validates_local_account_authority() {
+ let sandbox = RadrootsCliSandbox::new();
+ let listing_file = create_listing_draft(&sandbox, "local-dry-run-no-account");
+
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(value["operation_id"], "listing.publish");
+ assert_eq!(value["result"], serde_json::Value::Null);
+ assert_eq!(value["errors"][0]["code"], "account_unresolved");
+ assert_eq!(value["errors"][0]["detail"]["class"], "account");
+}
+
+#[test]
fn local_listing_publish_signs_with_selected_account_without_remote_fallback() {
let sandbox = RadrootsCliSandbox::new();
sandbox.json_success(&["--format", "json", "account", "create"]);
@@ -342,6 +363,29 @@ fn local_listing_publish_signs_with_selected_account_without_remote_fallback() {
}
#[test]
+fn local_listing_publish_dry_run_does_not_sign_matching_listing() {
+ let sandbox = RadrootsCliSandbox::new();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ let listing_file = create_listing_draft(&sandbox, "local-dry-run");
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
+
+ let value = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "publish",
+ listing_file.to_string_lossy().as_ref(),
+ ]);
+
+ assert_eq!(value["operation_id"], "listing.publish");
+ assert_eq!(value["dry_run"], true);
+ assert_eq!(value["result"]["state"], "dry_run");
+ assert_eq!(value["result"]["dry_run"], true);
+ assert_eq!(value["result"]["event_id"], serde_json::Value::Null);
+}
+
+#[test]
fn local_listing_publish_fails_when_selected_account_does_not_match_seller() {
let sandbox = RadrootsCliSandbox::new();
let first = sandbox.json_success(&["--format", "json", "account", "create"]);
diff --git a/tests/target_cli.rs b/tests/target_cli.rs
@@ -2,7 +2,10 @@ mod support;
use serde_json::Value;
-use support::{RadrootsCliSandbox, assert_no_removed_command_reference, radroots};
+use support::{
+ RadrootsCliSandbox, assert_no_removed_command_reference, create_listing_draft,
+ make_listing_publishable, radroots,
+};
const LISTING_ADDR: &str =
"30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg";
@@ -189,8 +192,9 @@ fn offline_forbids_external_network_operations() {
#[test]
fn offline_allows_supported_external_dry_run() {
let sandbox = RadrootsCliSandbox::new();
- let listing_file = sandbox.root().join("listing.toml");
- let listing_file = listing_file.to_string_lossy().into_owned();
+ sandbox.json_success(&["--format", "json", "account", "create"]);
+ let listing_file = create_listing_draft(&sandbox, "offline-dry-run");
+ make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw");
let publish = sandbox.json_success(&[
"--format",
@@ -199,7 +203,7 @@ fn offline_allows_supported_external_dry_run() {
"--dry-run",
"listing",
"publish",
- listing_file.as_str(),
+ listing_file.to_string_lossy().as_ref(),
]);
assert_eq!(publish["operation_id"], "listing.publish");
@@ -207,6 +211,25 @@ fn offline_allows_supported_external_dry_run() {
}
#[test]
+fn listing_publish_dry_run_validates_missing_file() {
+ let sandbox = RadrootsCliSandbox::new();
+ let missing = sandbox.root().join("missing-listing.toml");
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--dry-run",
+ "listing",
+ "publish",
+ missing.to_string_lossy().as_ref(),
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(value["operation_id"], "listing.publish");
+ assert_eq!(value["result"], Value::Null);
+ assert_eq!(value["errors"][0]["code"], "runtime_error");
+}
+
+#[test]
fn online_requires_relay_for_external_network_operations() {
let output = radroots()
.args(["--format", "json", "--online", "market", "refresh"])
@@ -310,6 +333,7 @@ fn buyer_target_flow_acceptance_uses_target_operations() {
assert_eq!(submit["operation_id"], "order.submit");
assert_eq!(submit["dry_run"], true);
assert_eq!(submit["result"]["state"], "unconfigured");
+ assert_eq!(submit["result"]["dry_run"], true);
assert_eq!(submit["result"]["order_id"], order_id);
assert!(
submit["result"]["reason"]