commit 45023069b422f3b717f3b7b02d4fe9c346dad9b7
parent 92089b929f382625d7ee944e10456f6633361023
Author: triesap <tyson@radroots.org>
Date: Thu, 7 May 2026 01:18:32 +0000
cli: add account secret attachment
Diffstat:
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()]