commit 106516a900d4e97c500105ee16036cc0f91efe94
parent bccb5d0432da311b68a4a003184e1a93330ff488
Author: triesap <tyson@radroots.org>
Date: Mon, 27 Apr 2026 06:24:07 +0000
cli: prove local account resolution
- wire account import get remove and selection inputs
- classify account selector failures as unresolved accounts
- test local signer default and invocation override resolution
- test watch-only import signer readiness boundaries
Diffstat:
4 files changed, 237 insertions(+), 15 deletions(-)
diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs
@@ -519,7 +519,9 @@ fn classify_runtime_failure(
&lowered,
&[
"no account",
+ "account selector",
"account selection",
+ "did not match any local account",
"unresolved account",
"selected account",
],
@@ -771,12 +773,30 @@ fn value_to_data(value: Value) -> OperationData {
fn target_operation_input(command: &crate::target_cli::TargetCommand) -> OperationData {
use crate::target_cli::{
- BasketCommand, BasketItemCommand, BasketQuoteCommand, ListingCommand, MarketCommand,
- MarketListingCommand, MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand,
+ AccountCommand, AccountSelectionCommand, BasketCommand, BasketItemCommand,
+ BasketQuoteCommand, ListingCommand, MarketCommand, MarketListingCommand,
+ MarketProductCommand, OrderCommand, OrderEventCommand, TargetCommand,
};
let mut input = OperationData::new();
match command {
+ TargetCommand::Account(args) => match &args.command {
+ AccountCommand::Import(args) => {
+ insert_path(&mut input, "path", &args.path);
+ if args.default {
+ input.insert("default".to_owned(), Value::Bool(true));
+ }
+ }
+ AccountCommand::Get(args) => insert_string(&mut input, "selector", &args.selector),
+ AccountCommand::Remove(args) => insert_string(&mut input, "selector", &args.selector),
+ AccountCommand::Selection(args) => match &args.command {
+ AccountSelectionCommand::Update(args) => {
+ insert_string(&mut input, "selector", &args.selector)
+ }
+ AccountSelectionCommand::Get | AccountSelectionCommand::Clear => {}
+ },
+ AccountCommand::Create | AccountCommand::List => {}
+ },
TargetCommand::Listing(args) => match &args.command {
ListingCommand::Create(args) => {
insert_path(&mut input, "output", &args.output);
diff --git a/src/operation_core.rs b/src/operation_core.rs
@@ -260,7 +260,9 @@ impl OperationService<AccountGetRequest> for CoreOperationService<'_> {
} else {
self.config
};
- let resolution = map_runtime(resolve_account_resolution(config))?;
+ let resolution = resolve_account_resolution(config).map_err(|error| {
+ OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
+ })?;
let reason = if resolution.resolved_account.is_some() {
None
} else {
@@ -315,7 +317,9 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> {
));
}
- let result = map_runtime(remove_account(self.config, selector.as_str()))?;
+ let result = remove_account(self.config, selector.as_str()).map_err(|error| {
+ OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
+ })?;
json_operation_result::<AccountRemoveResult>(json!({
"state": "removed",
"removed_account": account_summary_view(&result.removed_account),
@@ -354,7 +358,9 @@ impl OperationService<AccountSelectionUpdateRequest> for CoreOperationService<'_
}));
}
- let account = map_runtime(select_account(self.config, selector.as_str()))?;
+ let account = select_account(self.config, selector.as_str()).map_err(|error| {
+ OperationAdapterError::unconfigured(request.operation_id(), error.to_string())
+ })?;
json_operation_result::<AccountSelectionUpdateResult>(json!({
"state": "default",
"account": account_summary_view(&account),
diff --git a/src/target_cli.rs b/src/target_cli.rs
@@ -85,13 +85,13 @@ impl TargetCommand {
},
Self::Account(args) => match &args.command {
AccountCommand::Create => "account.create",
- AccountCommand::Import => "account.import",
- AccountCommand::Get => "account.get",
+ AccountCommand::Import(_) => "account.import",
+ AccountCommand::Get(_) => "account.get",
AccountCommand::List => "account.list",
- AccountCommand::Remove => "account.remove",
- AccountCommand::Selection(selection) => match selection.command {
+ AccountCommand::Remove(_) => "account.remove",
+ AccountCommand::Selection(selection) => match &selection.command {
AccountSelectionCommand::Get => "account.selection.get",
- AccountSelectionCommand::Update => "account.selection.update",
+ AccountSelectionCommand::Update(_) => "account.selection.update",
AccountSelectionCommand::Clear => "account.selection.clear",
},
},
@@ -268,23 +268,40 @@ pub struct AccountArgs {
#[derive(Debug, Clone, Subcommand)]
pub enum AccountCommand {
Create,
- Import,
- Get,
+ Import(AccountImportArgs),
+ Get(AccountGetArgs),
List,
- Remove,
+ Remove(AccountSelectorArgs),
Selection(AccountSelectionArgs),
}
#[derive(Debug, Clone, Args)]
+pub struct AccountImportArgs {
+ pub path: Option<PathBuf>,
+ #[arg(long, action = clap::ArgAction::SetTrue)]
+ pub default: bool,
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct AccountGetArgs {
+ pub selector: Option<String>,
+}
+
+#[derive(Debug, Clone, Args)]
+pub struct AccountSelectorArgs {
+ pub selector: Option<String>,
+}
+
+#[derive(Debug, Clone, Args)]
pub struct AccountSelectionArgs {
#[command(subcommand)]
pub command: AccountSelectionCommand,
}
-#[derive(Debug, Clone, Copy, Subcommand)]
+#[derive(Debug, Clone, Subcommand)]
pub enum AccountSelectionCommand {
Get,
- Update,
+ Update(AccountSelectorArgs),
Clear,
}
diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs
@@ -55,6 +55,171 @@ fn local_signer_status_reports_ready_after_account_create() {
}
#[test]
+fn local_account_selection_and_invocation_override_resolve_signer_actor() {
+ let sandbox = RadrootsCliSandbox::new();
+
+ let first = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let second = sandbox.json_success(&["--format", "json", "account", "create"]);
+ let first_account_id = first["result"]["account"]["id"]
+ .as_str()
+ .expect("first account id");
+ let second_account_id = second["result"]["account"]["id"]
+ .as_str()
+ .expect("second account id");
+ assert_eq!(first["result"]["account"]["is_default"], true);
+ assert_eq!(second["result"]["account"]["is_default"], false);
+
+ let default_status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
+ assert_eq!(default_status["result"]["state"], "ready");
+ assert_eq!(
+ default_status["result"]["signer_account_id"],
+ first_account_id
+ );
+ assert_eq!(
+ default_status["result"]["account_resolution"]["source"],
+ "default_account"
+ );
+
+ let override_status = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--account-id",
+ second_account_id,
+ "signer",
+ "status",
+ "get",
+ ]);
+ assert_eq!(override_status["actor"]["account_id"], second_account_id);
+ assert_eq!(override_status["actor"]["role"], "account");
+ assert_eq!(
+ override_status["result"]["signer_account_id"],
+ second_account_id
+ );
+ assert_eq!(
+ override_status["result"]["account_resolution"]["source"],
+ "invocation_override"
+ );
+ assert_eq!(
+ override_status["result"]["account_resolution"]["default_account"]["id"],
+ first_account_id
+ );
+
+ let selected = sandbox.json_success(&[
+ "--format",
+ "json",
+ "account",
+ "selection",
+ "update",
+ second_account_id,
+ ]);
+ assert_eq!(selected["operation_id"], "account.selection.update");
+ assert_eq!(selected["result"]["state"], "default");
+ assert_eq!(selected["result"]["account"]["id"], second_account_id);
+
+ let selected_status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
+ assert_eq!(
+ selected_status["result"]["signer_account_id"],
+ second_account_id
+ );
+ assert_eq!(
+ selected_status["result"]["account_resolution"]["source"],
+ "default_account"
+ );
+
+ let selected_get =
+ sandbox.json_success(&["--format", "json", "account", "get", first_account_id]);
+ assert_eq!(selected_get["operation_id"], "account.get");
+ assert_eq!(
+ selected_get["result"]["account_resolution"]["source"],
+ "invocation_override"
+ );
+ assert_eq!(
+ selected_get["result"]["account_resolution"]["resolved_account"]["id"],
+ first_account_id
+ );
+}
+
+#[test]
+fn unresolved_account_override_returns_account_failure() {
+ let sandbox = RadrootsCliSandbox::new();
+ let (output, value) = sandbox.json_output(&[
+ "--format",
+ "json",
+ "--account-id",
+ "missing-account",
+ "account",
+ "get",
+ ]);
+
+ assert!(!output.status.success());
+ assert_eq!(value["operation_id"], "account.get");
+ assert_eq!(value["result"], serde_json::Value::Null);
+ assert_eq!(value["errors"][0]["code"], "account_unresolved");
+ assert_eq!(value["errors"][0]["exit_code"], 5);
+ assert_eq!(value["errors"][0]["detail"]["class"], "account");
+ assert_contains(&value["errors"][0]["message"], "account selector");
+}
+
+#[test]
+fn watch_only_import_reports_unconfigured_local_signer() {
+ let sandbox = RadrootsCliSandbox::new();
+ let public_identity = identity_public(11);
+ let public_identity_file =
+ write_public_identity_profile(&sandbox, "watch-only", &public_identity);
+
+ let imported = sandbox.json_success(&[
+ "--format",
+ "json",
+ "--approval-token",
+ "approve",
+ "account",
+ "import",
+ "--default",
+ public_identity_file.to_string_lossy().as_ref(),
+ ]);
+
+ assert_eq!(imported["operation_id"], "account.import");
+ assert_eq!(imported["result"]["state"], "imported");
+ assert_eq!(
+ imported["result"]["account"]["id"],
+ public_identity.id.as_str()
+ );
+ assert_eq!(imported["result"]["account"]["signer"], "watch_only");
+ assert_eq!(imported["result"]["account"]["is_default"], true);
+
+ let status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]);
+
+ assert_eq!(status["result"]["mode"], "local");
+ assert_eq!(status["result"]["state"], "unconfigured");
+ assert_eq!(
+ status["result"]["signer_account_id"],
+ public_identity.id.as_str()
+ );
+ assert_eq!(
+ status["result"]["account_resolution"]["source"],
+ "default_account"
+ );
+ assert_eq!(
+ status["result"]["account_resolution"]["resolved_account"]["signer"],
+ "watch_only"
+ );
+ assert_eq!(
+ status["result"]["local"]["account_id"],
+ public_identity.id.as_str()
+ );
+ assert_eq!(status["result"]["local"]["availability"], "public_only");
+ assert_eq!(status["result"]["local"]["secret_backed"], false);
+ assert_contains(&status["result"]["reason"], "not secret-backed");
+ assert!(
+ status["result"]["write_kinds"]
+ .as_array()
+ .expect("write kinds")
+ .iter()
+ .all(|kind| kind["ready"] == false)
+ );
+}
+
+#[test]
fn myc_signer_status_reports_unavailable_for_missing_executable() {
let sandbox = RadrootsCliSandbox::new();
let missing_myc = sandbox.root().join("bin/missing-myc");
@@ -378,6 +543,20 @@ fn identity_public(seed: u8) -> RadrootsIdentityPublic {
.to_public()
}
+fn write_public_identity_profile(
+ sandbox: &RadrootsCliSandbox,
+ name: &str,
+ identity: &RadrootsIdentityPublic,
+) -> PathBuf {
+ let path = sandbox.root().join(format!("{name}.json"));
+ fs::write(
+ &path,
+ serde_json::to_string_pretty(identity).expect("public identity json"),
+ )
+ .expect("write public identity");
+ path
+}
+
fn shell_single_quoted(value: &str) -> String {
value.replace('\'', "'\"'\"'")
}