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:
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()]
+}