cli

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

commit 1fde769e6d5114e42f275be5a8214bce594457c3
parent bebee95eb806dd4b9c9a566e23c2a6668c89faf7
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 10:21:04 +0000

cli: normalize approval token validation

- trim approval tokens before required operation checks
- reuse the shared context guard across approval-gated mutations
- cover absent, empty, and whitespace tokens for required operations
- verify target cli and signer runtime mode integration tests

Diffstat:
Msrc/operation_adapter.rs | 10++++++++++
Msrc/operation_core.rs | 4++--
Msrc/operation_farm.rs | 2+-
Msrc/operation_listing.rs | 2+-
Msrc/operation_order.rs | 2+-
Mtests/target_cli.rs | 66+++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
6 files changed, 68 insertions(+), 18 deletions(-)

diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -118,6 +118,16 @@ impl OperationContext { }); context } + + pub fn requires_approval_token(&self) -> bool { + !self.dry_run && !self.has_approval_token() + } + + pub fn has_approval_token(&self) -> bool { + self.approval_token + .as_deref() + .is_some_and(|token| !token.trim().is_empty()) + } } pub type OperationData = Map<String, Value>; diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -233,7 +233,7 @@ impl OperationService<AccountImportRequest> for CoreOperationService<'_> { "account": account_summary_view(&account), })); } - if request.context.approval_token.is_none() { + if request.context.requires_approval_token() { return Err(OperationAdapterError::approval_required( request.operation_id(), )); @@ -321,7 +321,7 @@ impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> { "remaining_account_count": preview.remaining_account_count, })); } - if request.context.approval_token.is_none() { + if request.context.requires_approval_token() { return Err(OperationAdapterError::approval_required( request.operation_id(), )); diff --git a/src/operation_farm.rs b/src/operation_farm.rs @@ -143,7 +143,7 @@ impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { print_job: bool_input(&request, "print_job").unwrap_or(false), print_event: bool_input(&request, "print_event").unwrap_or(false), }; - if !request.context.dry_run && request.context.approval_token.is_none() { + if request.context.requires_approval_token() { return Err(OperationAdapterError::approval_required( request.operation_id(), )); diff --git a/src/operation_listing.rs b/src/operation_listing.rs @@ -208,7 +208,7 @@ fn require_approval<P>(request: &OperationRequest<P>) -> Result<(), OperationAda where P: OperationRequestPayload + OperationRequestData, { - if request.context.approval_token.is_none() { + if request.context.requires_approval_token() { return Err(OperationAdapterError::approval_required( request.operation_id(), )); diff --git a/src/operation_order.rs b/src/operation_order.rs @@ -29,7 +29,7 @@ impl OperationService<OrderSubmitRequest> for OrderOperationService<'_> { &self, request: OperationRequest<OrderSubmitRequest>, ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - if !request.context.dry_run && request.context.approval_token.is_none() { + if request.context.requires_approval_token() { return Err(OperationAdapterError::approval_required( request.operation_id(), )); diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -6,8 +6,8 @@ use std::path::Path; use serde_json::Value; use support::{ - RadrootsCliSandbox, assert_no_removed_command_reference, create_listing_draft, - make_listing_publishable, ndjson_from_stdout, radroots, + RadrootsCliSandbox, assert_no_removed_command_reference, create_listing_draft, identity_public, + make_listing_publishable, ndjson_from_stdout, radroots, write_public_identity_profile, }; const LISTING_ADDR: &str = @@ -542,18 +542,58 @@ fn store_backup_dry_run_preflights_initialized_store_without_writing_file() { } #[test] -fn required_approval_missing_token_returns_structured_error() { - let output = radroots() - .args(["--format", "json", "order", "submit"]) - .output() - .expect("run order submit"); - - assert_eq!(output.status.code(), Some(6)); - let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); +fn required_approval_token_rejects_absent_empty_and_whitespace_values() { + let sandbox = RadrootsCliSandbox::new(); + let public_identity = identity_public(61); + let public_identity_file = + write_public_identity_profile(&sandbox, "approval-import", &public_identity); + let public_identity_path = public_identity_file.to_string_lossy(); + + assert_required_approval_token_rejected( + &sandbox, + "account.import", + &["account", "import", public_identity_path.as_ref()], + ); + assert_required_approval_token_rejected( + &sandbox, + "account.remove", + &["account", "remove", "acct_missing"], + ); + assert_required_approval_token_rejected(&sandbox, "farm.publish", &["farm", "publish"]); + assert_required_approval_token_rejected( + &sandbox, + "listing.publish", + &["listing", "publish", "missing-listing.toml"], + ); + assert_required_approval_token_rejected( + &sandbox, + "listing.archive", + &["listing", "archive", "missing-listing.toml"], + ); + assert_required_approval_token_rejected(&sandbox, "order.submit", &["order", "submit"]); +} - assert_eq!(value["operation_id"], "order.submit"); - assert_eq!(value["errors"][0]["code"], "approval_required"); - assert_eq!(value["errors"][0]["exit_code"], 6); +fn assert_required_approval_token_rejected( + sandbox: &RadrootsCliSandbox, + operation_id: &str, + command_args: &[&str], +) { + for token in [None, Some(""), Some(" \t ")] { + let mut args = vec!["--format", "json"]; + if let Some(token) = token { + args.push("--approval-token"); + args.push(token); + } + args.extend_from_slice(command_args); + + let (output, value) = sandbox.json_output(&args); + + assert_eq!(output.status.code(), Some(6), "`{args:?}` should fail"); + assert_eq!(value["operation_id"], operation_id); + assert_eq!(value["errors"][0]["code"], "approval_required"); + assert_eq!(value["errors"][0]["exit_code"], 6); + assert_no_removed_command_reference(&value, &args); + } } #[test]