cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit da259c69fe1d9a17a327497f750f32c4e0f2c90b
parent 0790350ef9776eef27c35e730f79e0646ea2a573
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 05:29:51 +0000

cli: prove signer write failure boundaries

- add local account authority failure coverage
- prove myc publish does not use local fallback
- return unresolved signer writes as failure envelopes
- cover listing publish fixtures process-wide

Diffstat:
Msrc/operation_adapter.rs | 10++++++++++
Msrc/operation_listing.rs | 48++++++++++++++++++++++++++++++++++++++++++++++--
Msrc/operation_order.rs | 47++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/signer_runtime_modes.rs | 131++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 232 insertions(+), 4 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -306,6 +306,11 @@ pub enum OperationAdapterError { }, #[error("operation runtime error: {0}")] Runtime(String), + #[error("operation `{operation_id}` is unavailable or unconfigured: {message}")] + UnavailableOrUnconfigured { + operation_id: String, + message: String, + }, } impl OperationAdapterError { @@ -344,6 +349,11 @@ impl OperationAdapterError { Self::Runtime(message) => { OutputError::new("runtime_error", message.clone(), CliExitCode::InternalError) } + Self::UnavailableOrUnconfigured { message, .. } => OutputError::new( + "unavailable_or_unconfigured", + message.clone(), + CliExitCode::UnavailableOrUnconfigured, + ), } } } diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -6,6 +6,7 @@ use serde::Serialize; use serde_json::{Value, json}; use crate::cli::{ListingFileArgs, ListingMutationArgs, ListingNewArgs, RecordKeyArgs}; +use crate::domain::runtime::{CommandDisposition, ListingMutationView}; use crate::operation_adapter::{ ListingArchiveRequest, ListingArchiveResult, ListingCreateRequest, ListingCreateResult, ListingGetRequest, ListingGetResult, ListingListRequest, ListingListResult, @@ -141,7 +142,7 @@ impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { require_approval(&request)?; let args = mutation_args(&request)?; let view = map_runtime(crate::runtime::listing::publish(self.config, &args))?; - serialized_operation_result::<ListingPublishResult, _>(&view) + mutation_result::<ListingPublishResult>(request.operation_id(), &view) } } @@ -158,7 +159,7 @@ impl OperationService<ListingArchiveRequest> for ListingOperationService<'_> { require_approval(&request)?; let args = mutation_args(&request)?; let view = map_runtime(crate::runtime::listing::archive(self.config, &args))?; - serialized_operation_result::<ListingArchiveResult, _>(&view) + mutation_result::<ListingArchiveResult>(request.operation_id(), &view) } } @@ -221,6 +222,49 @@ where OperationResult::new(R::from_serializable(value)?) } +fn mutation_result<R>( + operation_id: &str, + view: &ListingMutationView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_operation_result::<R, _>(view), + disposition => Err(disposition_error( + operation_id, + disposition, + view.reason.clone().unwrap_or_else(|| { + format!( + "listing {} finished with state `{}`", + view.operation, view.state + ) + }), + )), + } +} + +fn disposition_error( + operation_id: &str, + disposition: CommandDisposition, + message: String, +) -> OperationAdapterError { + match disposition { + CommandDisposition::Success => OperationAdapterError::Runtime(message), + CommandDisposition::Unconfigured | CommandDisposition::ExternalUnavailable => { + OperationAdapterError::UnavailableOrUnconfigured { + operation_id: operation_id.to_owned(), + message, + } + } + CommandDisposition::Unsupported => OperationAdapterError::InvalidInput { + operation_id: operation_id.to_owned(), + message, + }, + CommandDisposition::InternalError => OperationAdapterError::Runtime(message), + } +} + fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> where R: OperationResultData, diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -4,6 +4,7 @@ use serde::Serialize; use serde_json::Value; use crate::cli::{OrderSubmitArgs, OrderWatchArgs, RecordKeyArgs}; +use crate::domain::runtime::{CommandDisposition, OrderSubmitView}; use crate::operation_adapter::{ OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, OperationService, OrderEventListRequest, @@ -56,7 +57,11 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> { config.output.dry_run = true; } let view = map_runtime(crate::runtime::order::submit(&config, &args))?; - serialized_target_result::<OrderSubmitResult, _>(&view) + if request.context.dry_run { + serialized_target_result::<OrderSubmitResult, _>(&view) + } else { + submit_result::<OrderSubmitResult>(request.operation_id(), &view) + } } } @@ -127,6 +132,46 @@ where OperationResult::new(R::from_value(value)) } +fn submit_result<R>( + operation_id: &str, + view: &OrderSubmitView, +) -> Result<OperationResult<R>, OperationAdapterError> +where + R: OperationResultData, +{ + match view.disposition() { + CommandDisposition::Success => serialized_target_result::<R, _>(view), + disposition => Err(disposition_error( + operation_id, + disposition, + view.reason + .clone() + .unwrap_or_else(|| format!("order submit finished with state `{}`", view.state)), + )), + } +} + +fn disposition_error( + operation_id: &str, + disposition: CommandDisposition, + message: String, +) -> OperationAdapterError { + match disposition { + CommandDisposition::Success => OperationAdapterError::Runtime(message), + CommandDisposition::Unconfigured | CommandDisposition::ExternalUnavailable => { + OperationAdapterError::UnavailableOrUnconfigured { + operation_id: operation_id.to_owned(), + message, + } + } + CommandDisposition::Unsupported => OperationAdapterError::InvalidInput { + operation_id: operation_id.to_owned(), + message, + }, + CommandDisposition::InternalError => OperationAdapterError::Runtime(message), + } +} + fn translate_actions_in_value(value: &mut Value) { match value { Value::Object(object) => { diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -1,6 +1,7 @@ mod support; -use std::path::Path; +use std::fs; +use std::path::{Path, PathBuf}; use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use serde_json::{Value, json}; @@ -235,6 +236,64 @@ fn myc_binding_reports_ready_for_one_authorized_session() { ); } +#[test] +fn local_listing_publish_fails_without_local_account_authority() { + let sandbox = RadrootsCliSandbox::new(); + let listing_file = create_listing_draft(&sandbox, "local-no-account"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--approval-token", + "approve", + "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"], "runtime_error"); + assert_eq!(value["errors"][0]["exit_code"], 1); + assert_contains( + &value["errors"][0]["message"], + "no local account is selected", + ); +} + +#[cfg(unix)] +#[test] +fn myc_listing_publish_does_not_fallback_to_local_account() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let listing_file = create_listing_draft(&sandbox, "myc-no-binding"); + make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); + let myc = write_fake_myc_status( + &sandbox, + "myc-ready-no-write-binding", + ready_myc_payload(Vec::new()), + ); + configure_myc_mode(&sandbox, &myc); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "--approval-token", + "approve", + "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"], "unavailable_or_unconfigured"); + assert_eq!(value["errors"][0]["exit_code"], 3); + assert_contains(&value["errors"][0]["message"], "signer.remote_nip46"); +} + fn configure_myc_mode(sandbox: &RadrootsCliSandbox, executable: &Path) { sandbox.write_app_config(&format!( "[signer]\nmode = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n", @@ -333,3 +392,73 @@ fn assert_contains(value: &Value, needle: &str) { "expected `{value}` to contain `{needle}`" ); } + +fn create_listing_draft(sandbox: &RadrootsCliSandbox, key: &str) -> PathBuf { + let listing_file = sandbox.root().join(format!("{key}.toml")); + let listing_file_arg = listing_file.to_string_lossy(); + let value = sandbox.json_success(&[ + "--format", + "json", + "listing", + "create", + "--output", + listing_file_arg.as_ref(), + "--key", + key, + "--title", + "Eggs", + "--category", + "eggs", + "--summary", + "Fresh eggs", + "--bin-id", + "bin-1", + "--quantity-amount", + "1", + "--quantity-unit", + "each", + "--price-amount", + "6", + "--price-currency", + "USD", + "--price-per-amount", + "1", + "--price-per-unit", + "each", + "--available", + "10", + ]); + assert_eq!(value["operation_id"], "listing.create"); + listing_file +} + +fn make_listing_publishable(path: &Path, farm_d_tag: &str) { + let raw = fs::read_to_string(path).expect("listing draft"); + let mut seller_pubkey_present = false; + let patched = raw + .lines() + .map(|line| { + let trimmed = line.trim_start(); + if trimmed.starts_with("seller_pubkey =") { + seller_pubkey_present = !trimmed.ends_with("\"\""); + line.to_owned() + } else if trimmed.starts_with("farm_d_tag =") { + format!("{}farm_d_tag = \"{}\"", line_indent(line), farm_d_tag) + } else if trimmed.starts_with("method =") { + format!("{}method = \"pickup\"", line_indent(line)) + } else if trimmed.starts_with("primary =") { + format!("{}primary = \"farmstand\"", line_indent(line)) + } else { + line.to_owned() + } + }) + .collect::<Vec<_>>() + .join("\n"); + assert!(seller_pubkey_present, "listing draft seller pubkey"); + fs::write(path, format!("{patched}\n")).expect("write listing draft"); +} + +fn line_indent(line: &str) -> &str { + let trimmed = line.trim_start(); + &line[..line.len() - trimmed.len()] +}