cli

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

commit 45023069b422f3b717f3b7b02d4fe9c346dad9b7
parent 92089b929f382625d7ee944e10456f6633361023
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 01:18:32 +0000

cli: add account secret attachment

Diffstat:
Msrc/main.rs | 3+++
Msrc/operation_adapter.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/operation_core.rs | 128++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Msrc/operation_registry.rs | 20+++++++++++++++++++-
Msrc/runtime/accounts.rs | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/target_cli.rs | 43++++++++++++++++++++++++++++++++++++++++---
Mtests/signer_runtime_modes.rs | 250++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/support/mod.rs | 18+++++++++++++++---
8 files changed, 556 insertions(+), 28 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -135,6 +135,9 @@ fn execute_request( TargetOperationRequest::AccountImport(request) => { execute_with(CoreOperationService::new(config, logging), request) } + TargetOperationRequest::AccountAttachSecret(request) => { + execute_with(CoreOperationService::new(config, logging), request) + } TargetOperationRequest::AccountGet(request) => { execute_with(CoreOperationService::new(config, logging), request) } diff --git a/src/operation_adapter.rs b/src/operation_adapter.rs @@ -519,6 +519,11 @@ impl OperationAdapterError { &lowered, &[ "no local account", + "account selector", + "account selection", + "account mismatch", + "did not match any local account", + "unresolved account", "watch_only", "not secret-backed", "selected local account", @@ -1090,6 +1095,13 @@ fn target_operation_input(command: &crate::target_cli::TargetCommand) -> Operati input.insert("default".to_owned(), Value::Bool(true)); } } + AccountCommand::AttachSecret(args) => { + insert_string(&mut input, "selector", &args.selector); + 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 { @@ -1365,6 +1377,7 @@ target_operation_contracts! { ConfigGet => (ConfigGetRequest, ConfigGetResult, "config.get"), AccountCreate => (AccountCreateRequest, AccountCreateResult, "account.create"), AccountImport => (AccountImportRequest, AccountImportResult, "account.import"), + AccountAttachSecret => (AccountAttachSecretRequest, AccountAttachSecretResult, "account.attach_secret"), AccountGet => (AccountGetRequest, AccountGetResult, "account.get"), AccountList => (AccountListRequest, AccountListResult, "account.list"), AccountRemove => (AccountRemoveRequest, AccountRemoveResult, "account.remove"), @@ -1528,6 +1541,47 @@ mod tests { } #[test] + fn adapter_maps_account_attach_secret_input() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "account", + "attach-secret", + "acct_test", + "identity.json", + "--default", + ]) + .expect("target args parse"); + + let request = TargetOperationRequest::from_target_args(&parsed) + .expect("operation request from target args"); + let TargetOperationRequest::AccountAttachSecret(request) = request else { + panic!("expected account attach-secret request") + }; + + assert_eq!(request.operation_id(), "account.attach_secret"); + assert_eq!( + request + .payload + .input + .get("selector") + .and_then(Value::as_str), + Some("acct_test") + ); + assert_eq!( + request.payload.input.get("path").and_then(Value::as_str), + Some("identity.json") + ); + assert_eq!( + request + .payload + .input + .get("default") + .and_then(Value::as_bool), + Some(true) + ); + } + + #[test] fn adapter_maps_order_fulfillment_update_input() { let parsed = TargetCliArgs::try_parse_from([ "radroots", diff --git a/src/operation_core.rs b/src/operation_core.rs @@ -5,25 +5,26 @@ use serde_json::{Value, json}; use crate::domain::runtime::{CommandDisposition, LocalBackupView}; use crate::operation_adapter::{ - AccountCreateRequest, AccountCreateResult, AccountGetRequest, AccountGetResult, - AccountImportRequest, AccountImportResult, AccountListRequest, AccountListResult, - AccountRemoveRequest, AccountRemoveResult, AccountSelectionClearRequest, - AccountSelectionClearResult, AccountSelectionGetRequest, AccountSelectionGetResult, - AccountSelectionUpdateRequest, AccountSelectionUpdateResult, ConfigGetRequest, ConfigGetResult, - HealthCheckRunRequest, HealthCheckRunResult, HealthStatusGetRequest, HealthStatusGetResult, - OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, StoreBackupCreateRequest, - StoreBackupCreateResult, StoreExportRequest, StoreExportResult, StoreInitRequest, - StoreInitResult, StoreStatusGetRequest, StoreStatusGetResult, WorkspaceGetRequest, - WorkspaceGetResult, WorkspaceInitRequest, WorkspaceInitResult, + AccountAttachSecretRequest, AccountAttachSecretResult, AccountCreateRequest, + AccountCreateResult, AccountGetRequest, AccountGetResult, AccountImportRequest, + AccountImportResult, AccountListRequest, AccountListResult, AccountRemoveRequest, + AccountRemoveResult, AccountSelectionClearRequest, AccountSelectionClearResult, + AccountSelectionGetRequest, AccountSelectionGetResult, AccountSelectionUpdateRequest, + AccountSelectionUpdateResult, ConfigGetRequest, ConfigGetResult, HealthCheckRunRequest, + HealthCheckRunResult, HealthStatusGetRequest, HealthStatusGetResult, OperationAdapterError, + OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult, + OperationResultData, OperationService, StoreBackupCreateRequest, StoreBackupCreateResult, + StoreExportRequest, StoreExportResult, StoreInitRequest, StoreInitResult, + StoreStatusGetRequest, StoreStatusGetResult, WorkspaceGetRequest, WorkspaceGetResult, + WorkspaceInitRequest, WorkspaceInitResult, }; use crate::runtime::RuntimeError; use crate::runtime::accounts::{ - account_resolution_view, account_summary_view, clear_default_account, + account_resolution_view, account_summary_view, attach_identity_secret, clear_default_account, create_or_migrate_default_account, import_public_identity, preview_account_removal, - preview_public_identity_import, remove_account, resolve_account_resolution, - resolve_account_selector, secret_backend_status, select_account, snapshot, - unresolved_account_reason, + preview_identity_secret_attachment, preview_public_identity_import, remove_account, + resolve_account_resolution, resolve_account_selector, secret_backend_status, select_account, + snapshot, unresolved_account_reason, }; use crate::runtime::config::RuntimeConfig; use crate::runtime::logging::LoggingState; @@ -266,6 +267,63 @@ impl OperationService<AccountImportRequest> for CoreOperationService<'_> { } } +impl OperationService<AccountAttachSecretRequest> for CoreOperationService<'_> { + type Result = AccountAttachSecretResult; + + fn execute( + &self, + request: OperationRequest<AccountAttachSecretRequest>, + ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { + let selector = required_string(&request, "selector")?; + let path = required_path(&request, "path")?; + let make_default = bool_input(&request, "default").unwrap_or(false); + if request.context.dry_run { + let secret_backend = account_secret_backend_ready(request.operation_id(), self.config)?; + let account = map_expected_runtime( + request.operation_id(), + preview_identity_secret_attachment( + self.config, + selector.as_str(), + path.as_path(), + make_default, + ), + )?; + return json_operation_result::<AccountAttachSecretResult>(json!({ + "state": "dry_run", + "path": path.display().to_string(), + "default": make_default, + "secret_backend": { + "state": secret_backend.state, + "active_backend": secret_backend.active_backend, + "used_fallback": secret_backend.used_fallback, + }, + "account": account_summary_view(&account), + })); + } + if request.context.requires_approval_token() { + return Err(OperationAdapterError::approval_required( + request.operation_id(), + )); + } + + let secret_backend = account_secret_backend_ready(request.operation_id(), self.config)?; + let account = map_expected_runtime( + request.operation_id(), + attach_identity_secret(self.config, selector.as_str(), path.as_path(), make_default), + )?; + json_operation_result::<AccountAttachSecretResult>(json!({ + "state": "secret_attached", + "default": make_default, + "secret_backend": { + "state": secret_backend.state, + "active_backend": secret_backend.active_backend, + "used_fallback": secret_backend.used_fallback, + }, + "account": account_summary_view(&account), + })) + } +} + impl OperationService<AccountGetRequest> for CoreOperationService<'_> { type Result = AccountGetResult; @@ -532,6 +590,23 @@ fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapter result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) } +fn account_secret_backend_ready( + operation_id: &str, + config: &RuntimeConfig, +) -> Result<crate::runtime::accounts::AccountSecretBackendStatus, OperationAdapterError> { + let secret_backend = secret_backend_status(config); + if secret_backend.state == "ready" { + return Ok(secret_backend); + } + + Err(OperationAdapterError::OperationUnavailable { + operation_id: operation_id.to_owned(), + message: secret_backend + .reason + .unwrap_or_else(|| "account secret backend is not available".to_owned()), + }) +} + fn map_expected_runtime<T>( operation_id: &str, result: Result<T, RuntimeError>, @@ -643,9 +718,9 @@ mod tests { use super::CoreOperationService; use crate::operation_adapter::{ - AccountCreateRequest, AccountImportRequest, AccountListRequest, AccountRemoveRequest, - OperationAdapter, OperationContext, OperationData, OperationRequest, StoreStatusGetRequest, - WorkspaceGetRequest, + AccountAttachSecretRequest, AccountCreateRequest, AccountImportRequest, AccountListRequest, + AccountRemoveRequest, OperationAdapter, OperationContext, OperationData, OperationRequest, + StoreStatusGetRequest, WorkspaceGetRequest, }; use crate::runtime::config::{ AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, @@ -758,6 +833,23 @@ mod tests { assert_eq!(import_error.to_output_error().code, "approval_required"); assert_eq!(import_error.to_output_error().exit_code, 6); + let attach_secret = OperationRequest::new( + OperationContext::default(), + AccountAttachSecretRequest::from_data(data(&[ + ("selector", "acct_test"), + ("path", "account.json"), + ])), + ) + .expect("account attach-secret request"); + let attach_secret_error = service + .execute(attach_secret) + .expect_err("approval required"); + assert_eq!( + attach_secret_error.to_output_error().code, + "approval_required" + ); + assert_eq!(attach_secret_error.to_output_error().exit_code, 6); + let remove = OperationRequest::new( OperationContext::default(), AccountRemoveRequest::from_data(data(&[("selector", "acct_test")])), diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -182,6 +182,21 @@ pub const OPERATION_REGISTRY: &[OperationSpec] = &[ true ), operation!( + "account.attach_secret", + "radroots account attach-secret", + "account", + "account_attach_secret", + "AccountAttachSecretRequest", + "AccountAttachSecretResult", + "Attach local secret custody to an existing account.", + Any, + true, + Required, + High, + false, + true + ), + operation!( "account.get", "radroots account get", "account", @@ -1112,6 +1127,7 @@ mod tests { "config.get", "account.create", "account.import", + "account.attach_secret", "account.get", "account.list", "account.remove", @@ -1178,6 +1194,7 @@ mod tests { "workspace.init", "account.create", "account.import", + "account.attach_secret", "account.remove", "account.selection.update", "account.selection.clear", @@ -1223,7 +1240,7 @@ mod tests { .copied() .collect::<BTreeSet<_>>(); assert_eq!(actual, expected); - assert_eq!(OPERATION_REGISTRY.len(), 67); + assert_eq!(OPERATION_REGISTRY.len(), 68); } #[test] @@ -1265,6 +1282,7 @@ mod tests { .collect::<BTreeSet<_>>(); let expected_required = [ "account.import", + "account.attach_secret", "account.remove", "farm.publish", "listing.publish", diff --git a/src/runtime/accounts.rs b/src/runtime/accounts.rs @@ -176,6 +176,45 @@ pub fn preview_public_identity_import( }) } +pub fn preview_identity_secret_attachment( + config: &RuntimeConfig, + selector: &str, + path: &Path, + make_default: bool, +) -> Result<AccountRecordView, RuntimeError> { + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + let mut account = resolve_selector_account(&manager, &snapshot, selector)?; + let identity = load_secret_identity_for_attachment(path)?; + validate_identity_secret_matches_account(&account.record, &identity)?; + if make_default { + account.is_default = true; + } + account.signer = "local"; + Ok(account) +} + +pub fn attach_identity_secret( + config: &RuntimeConfig, + selector: &str, + path: &Path, + make_default: bool, +) -> Result<AccountRecordView, RuntimeError> { + let manager = account_manager(config)?; + let snapshot = snapshot_from_manager(&manager)?; + let account = resolve_selector_account(&manager, &snapshot, selector)?; + let identity = load_secret_identity_for_attachment(path)?; + validate_identity_secret_matches_account(&account.record, &identity)?; + let attached = + manager.attach_identity_secret(&account.record.account_id, &identity, make_default)?; + let snapshot = snapshot_from_manager(&manager)?; + snapshot_account( + &snapshot, + &attached.account_id, + "attached account missing after account attach-secret", + ) +} + pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> { let manager = account_manager(config)?; snapshot_from_manager(&manager) @@ -503,6 +542,35 @@ fn load_public_identity_for_import(path: &Path) -> Result<RadrootsIdentityPublic }) } +fn load_secret_identity_for_attachment(path: &Path) -> Result<RadrootsIdentity, RuntimeError> { + RadrootsIdentity::load_from_path_auto(path).map_err(|error| { + RuntimeError::Config(format!( + "failed to import account secret from {}: {}", + path.display(), + format_identity_error(error) + )) + }) +} + +fn validate_identity_secret_matches_account( + record: &RadrootsNostrAccountRecord, + identity: &RadrootsIdentity, +) -> Result<(), RuntimeError> { + let secret_public_key_hex = identity.public_key_hex(); + if record + .public_identity + .public_key_hex + .eq_ignore_ascii_case(secret_public_key_hex.as_str()) + { + return Ok(()); + } + + Err(RuntimeError::Config(format!( + "account mismatch: account `{}` public key `{}` does not match secret public key `{}`", + record.account_id, record.public_identity.public_key_hex, secret_public_key_hex + ))) +} + fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> { let (manager, _) = RadrootsNostrAccountsManager::new_local_file_backed( config.account.store_path.as_path(), diff --git a/src/target_cli.rs b/src/target_cli.rs @@ -84,6 +84,7 @@ impl TargetCommand { Self::Account(args) => match &args.command { AccountCommand::Create => "account.create", AccountCommand::Import(_) => "account.import", + AccountCommand::AttachSecret(_) => "account.attach_secret", AccountCommand::Get(_) => "account.get", AccountCommand::List => "account.list", AccountCommand::Remove(_) => "account.remove", @@ -276,6 +277,7 @@ pub struct AccountArgs { pub enum AccountCommand { Create, Import(AccountImportArgs), + AttachSecret(AccountAttachSecretArgs), Get(AccountGetArgs), List, Remove(AccountSelectorArgs), @@ -290,6 +292,14 @@ pub struct AccountImportArgs { } #[derive(Debug, Clone, Args)] +pub struct AccountAttachSecretArgs { + pub selector: Option<String>, + pub path: Option<PathBuf>, + #[arg(long, action = clap::ArgAction::SetTrue)] + pub default: bool, +} + +#[derive(Debug, Clone, Args)] pub struct AccountGetArgs { pub selector: Option<String>, } @@ -989,9 +999,9 @@ mod tests { use clap::{CommandFactory, Parser}; use super::{ - OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, OrderPaymentCommand, - OrderReceiptCommand, OrderRevisionCommand, OrderSettlementCommand, TargetCliArgs, - TargetOutputFormat, + AccountCommand, OrderCommand, OrderFulfillmentCommand, OrderFulfillmentStateArg, + OrderPaymentCommand, OrderReceiptCommand, OrderRevisionCommand, OrderSettlementCommand, + TargetCliArgs, TargetOutputFormat, }; use crate::operation_registry::OPERATION_REGISTRY; @@ -1080,6 +1090,33 @@ mod tests { } #[test] + fn target_parser_accepts_account_attach_secret_inputs() { + let parsed = TargetCliArgs::try_parse_from([ + "radroots", + "account", + "attach-secret", + "acct_test", + "identity.json", + "--default", + ]) + .expect("target args parse"); + + assert_eq!(parsed.command.operation_id(), "account.attach_secret"); + let crate::target_cli::TargetCommand::Account(account) = parsed.command else { + panic!("expected account command") + }; + let AccountCommand::AttachSecret(args) = account.command else { + panic!("expected account attach-secret command") + }; + assert_eq!(args.selector.as_deref(), Some("acct_test")); + assert_eq!( + args.path.as_ref().map(|path| path.as_os_str()), + Some(std::ffi::OsStr::new("identity.json")) + ); + assert!(args.default); + } + + #[test] fn target_parser_accepts_order_fulfillment_update_state() { let parsed = TargetCliArgs::try_parse_from([ "radroots", diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -1,12 +1,13 @@ mod support; +use std::fs; use std::path::Path; use support::{ RadrootsCliSandbox, assert_contains, assert_no_daemon_runtime_reference, - assert_no_removed_command_reference, create_listing_draft, identity_public, - make_listing_publishable, seed_orderable_listing, shell_single_quoted, toml_string, - write_public_identity_profile, + assert_no_removed_command_reference, create_listing_draft, identity_public, identity_secret, + json_from_stdout, make_listing_publishable, seed_orderable_listing, shell_single_quoted, + toml_string, write_public_identity_profile, write_secret_identity_profile, }; const LISTING_ADDR: &str = @@ -196,6 +197,249 @@ fn account_import_dry_run_validates_missing_profile_file() { } #[test] +fn account_attach_secret_dry_run_validates_without_mutating_store() { + let sandbox = RadrootsCliSandbox::new(); + let default_account = sandbox.json_success(&["--format", "json", "account", "create"]); + let default_account_id = default_account["result"]["account"]["id"] + .as_str() + .expect("default account id"); + let identity = identity_secret(31); + let public_identity = identity.to_public(); + let public_identity_file = + write_public_identity_profile(&sandbox, "attach-dry-public", &public_identity); + let secret_identity_file = + write_secret_identity_profile(&sandbox, "attach-dry-secret", &identity); + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + public_identity_file.to_string_lossy().as_ref(), + ]); + let watch_account_id = imported["result"]["account"]["id"] + .as_str() + .expect("watch account id"); + assert_eq!(imported["result"]["account"]["signer"], "watch_only"); + assert_eq!(imported["result"]["account"]["is_default"], false); + + let value = sandbox.json_success(&[ + "--format", + "json", + "--dry-run", + "account", + "attach-secret", + watch_account_id, + secret_identity_file.to_string_lossy().as_ref(), + "--default", + ]); + + assert_eq!(value["operation_id"], "account.attach_secret"); + assert_eq!(value["dry_run"], true); + assert_eq!(value["result"]["state"], "dry_run"); + assert_eq!(value["result"]["default"], true); + assert_eq!(value["result"]["account"]["id"], watch_account_id); + assert_eq!(value["result"]["account"]["signer"], "local"); + assert_eq!(value["result"]["account"]["is_default"], true); + + let watch_get = sandbox.json_success(&["--format", "json", "account", "get", watch_account_id]); + assert_eq!( + watch_get["result"]["account_resolution"]["resolved_account"]["signer"], + "watch_only" + ); + let selected = sandbox.json_success(&["--format", "json", "account", "selection", "get"]); + assert_eq!( + selected["result"]["account_resolution"]["resolved_account"]["id"], + default_account_id + ); +} + +#[test] +fn account_attach_secret_attaches_matching_secret_and_can_make_default() { + let sandbox = RadrootsCliSandbox::new(); + sandbox.json_success(&["--format", "json", "account", "create"]); + let identity = identity_secret(32); + let public_identity = identity.to_public(); + let public_identity_file = + write_public_identity_profile(&sandbox, "attach-public", &public_identity); + let secret_identity_file = write_secret_identity_profile(&sandbox, "attach-secret", &identity); + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + public_identity_file.to_string_lossy().as_ref(), + ]); + let watch_account_id = imported["result"]["account"]["id"] + .as_str() + .expect("watch account id"); + + let attached = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "attach-secret", + watch_account_id, + secret_identity_file.to_string_lossy().as_ref(), + "--default", + ]); + + assert_eq!(attached["operation_id"], "account.attach_secret"); + assert_eq!(attached["result"]["state"], "secret_attached"); + assert_eq!(attached["result"]["account"]["id"], watch_account_id); + assert_eq!(attached["result"]["account"]["signer"], "local"); + assert_eq!(attached["result"]["account"]["is_default"], true); + + let status = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + assert_eq!(status["result"]["state"], "ready"); + assert_eq!(status["result"]["signer_account_id"], watch_account_id); + assert_eq!(status["result"]["local"]["availability"], "secret_backed"); +} + +#[test] +fn account_attach_secret_requires_approval_before_writing_secret() { + let sandbox = RadrootsCliSandbox::new(); + let identity = identity_secret(33); + let public_identity = identity.to_public(); + let public_identity_file = + write_public_identity_profile(&sandbox, "attach-approval-public", &public_identity); + let secret_identity_file = + write_secret_identity_profile(&sandbox, "attach-approval-secret", &identity); + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + "--default", + public_identity_file.to_string_lossy().as_ref(), + ]); + let account_id = imported["result"]["account"]["id"] + .as_str() + .expect("account id"); + + let (output, value) = sandbox.json_output(&[ + "--format", + "json", + "account", + "attach-secret", + account_id, + secret_identity_file.to_string_lossy().as_ref(), + ]); + + assert!(!output.status.success()); + assert_eq!(value["operation_id"], "account.attach_secret"); + assert_eq!(value["errors"][0]["code"], "approval_required"); + assert_eq!(value["errors"][0]["exit_code"], 6); + let get = sandbox.json_success(&["--format", "json", "account", "get", account_id]); + assert_eq!( + get["result"]["account_resolution"]["resolved_account"]["signer"], + "watch_only" + ); +} + +#[test] +fn account_attach_secret_reports_structured_validation_failures() { + let sandbox = RadrootsCliSandbox::new(); + let matching_identity = identity_secret(34); + let public_identity = matching_identity.to_public(); + let public_identity_file = + write_public_identity_profile(&sandbox, "attach-fail-public", &public_identity); + let secret_identity_file = + write_secret_identity_profile(&sandbox, "attach-fail-secret", &matching_identity); + let imported = sandbox.json_success(&[ + "--format", + "json", + "--approval-token", + "approve", + "account", + "import", + public_identity_file.to_string_lossy().as_ref(), + ]); + let account_id = imported["result"]["account"]["id"] + .as_str() + .expect("account id"); + + let (missing_input_output, missing_input) = + sandbox.json_output(&["--format", "json", "--dry-run", "account", "attach-secret"]); + assert!(!missing_input_output.status.success()); + assert_eq!(missing_input["operation_id"], "account.attach_secret"); + assert_eq!(missing_input["errors"][0]["code"], "invalid_input"); + + let (missing_account_output, missing_account) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "account", + "attach-secret", + "missing-account", + secret_identity_file.to_string_lossy().as_ref(), + ]); + assert!(!missing_account_output.status.success()); + assert_eq!(missing_account["errors"][0]["code"], "account_unresolved"); + assert_eq!(missing_account["errors"][0]["exit_code"], 5); + + let mismatched_identity = identity_secret(35); + let mismatched_identity_file = + write_secret_identity_profile(&sandbox, "attach-mismatch-secret", &mismatched_identity); + let (mismatch_output, mismatch) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "account", + "attach-secret", + account_id, + mismatched_identity_file.to_string_lossy().as_ref(), + ]); + assert!(!mismatch_output.status.success()); + assert_eq!(mismatch["errors"][0]["code"], "account_mismatch"); + assert_eq!(mismatch["errors"][0]["exit_code"], 5); + + let invalid_identity_file = sandbox.root().join("attach-invalid-secret.json"); + fs::write(&invalid_identity_file, "{ invalid json").expect("write invalid identity"); + let (invalid_output, invalid) = sandbox.json_output(&[ + "--format", + "json", + "--dry-run", + "account", + "attach-secret", + account_id, + invalid_identity_file.to_string_lossy().as_ref(), + ]); + assert!(!invalid_output.status.success()); + assert_eq!(invalid["errors"][0]["code"], "validation_failed"); + assert_eq!(invalid["errors"][0]["exit_code"], 10); + + let mut unavailable_command = sandbox.command(); + unavailable_command + .env("RADROOTS_ACCOUNT_SECRET_BACKEND", "host_vault") + .env("RADROOTS_ACCOUNT_SECRET_FALLBACK", "none") + .env("RADROOTS_ACCOUNT_HOST_VAULT_AVAILABLE", "false") + .args([ + "--format", + "json", + "--dry-run", + "account", + "attach-secret", + account_id, + secret_identity_file.to_string_lossy().as_ref(), + ]); + let unavailable_output = unavailable_command + .output() + .expect("run unavailable backend"); + let unavailable = json_from_stdout(&unavailable_output); + assert!(!unavailable_output.status.success()); + assert_eq!(unavailable["errors"][0]["code"], "operation_unavailable"); + assert_eq!(unavailable["errors"][0]["exit_code"], 3); +} + +#[test] fn account_remove_dry_run_validates_selector_without_mutating_store() { let sandbox = RadrootsCliSandbox::new(); let created = sandbox.json_success(&["--format", "json", "account", "create"]); diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -347,10 +347,12 @@ pub fn create_listing_draft(sandbox: &RadrootsCliSandbox, key: &str) -> PathBuf } pub fn identity_public(seed: u8) -> RadrootsIdentityPublic { + identity_secret(seed).to_public() +} + +pub fn identity_secret(seed: u8) -> RadrootsIdentity { let secret = [seed; 32]; - RadrootsIdentity::from_secret_key_bytes(&secret) - .expect("fixture identity") - .to_public() + RadrootsIdentity::from_secret_key_bytes(&secret).expect("fixture identity") } pub fn make_listing_publishable(path: &Path, farm_d_tag: &str) { @@ -401,6 +403,16 @@ pub fn write_public_identity_profile( path } +pub fn write_secret_identity_profile( + sandbox: &RadrootsCliSandbox, + name: &str, + identity: &RadrootsIdentity, +) -> PathBuf { + let path = sandbox.root().join(format!("{name}.json")); + identity.save_json(&path).expect("write secret identity"); + path +} + fn line_indent(line: &str) -> &str { let trimmed = line.trim_start(); &line[..line.len() - trimmed.len()]