cli

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

commit 0790350ef9776eef27c35e730f79e0646ea2a573
parent 9cfb29d759aa4c284910d262c37433d582f474b3
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 05:16:49 +0000

cli: prove myc signer binding readiness

- add deterministic fake myc session payloads
- cover unsupported and unavailable binding states
- cover ambiguous and ready authorized sessions
- serialize integration command execution for stability

Diffstat:
Mtests/signer_runtime_modes.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mtests/support/mod.rs | 5+++++
2 files changed, 201 insertions(+), 3 deletions(-)

diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -2,7 +2,8 @@ mod support; use std::path::Path; -use serde_json::Value; +use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; +use serde_json::{Value, json}; use support::RadrootsCliSandbox; #[test] @@ -104,9 +105,10 @@ fn myc_signer_status_reports_unavailable_for_invalid_json() { #[test] fn myc_signer_status_reports_unconfigured_when_ready_without_binding() { let sandbox = RadrootsCliSandbox::new(); - let myc = sandbox.write_fake_myc( + let myc = write_fake_myc_status( + &sandbox, "myc-ready-no-binding", - r#"printf '%s\n' '{"status_contract_version":1,"status":"ready","ready":true,"signer_backend":{"remote_session_count":0,"remote_sessions":[]}}'"#, + ready_myc_payload(Vec::new()), ); configure_myc_mode(&sandbox, &myc); @@ -122,6 +124,117 @@ fn myc_signer_status_reports_unconfigured_when_ready_without_binding() { assert_contains(&value["result"]["binding"]["reason"], "signer.remote_nip46"); } +#[cfg(unix)] +#[test] +fn myc_binding_reports_unsupported_target_kind() { + let sandbox = RadrootsCliSandbox::new(); + let myc = write_fake_myc_status( + &sandbox, + "myc-ready-unsupported", + ready_myc_payload(Vec::new()), + ); + configure_myc_mode_with_binding(&sandbox, &myc, "explicit_endpoint", "default", None, None); + + let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + + assert_eq!(value["result"]["mode"], "myc"); + assert_eq!(value["result"]["state"], "unconfigured"); + assert_eq!(value["result"]["binding"]["state"], "unsupported"); + assert_contains(&value["result"]["binding"]["reason"], "target_kind"); +} + +#[cfg(unix)] +#[test] +fn myc_binding_reports_no_authorized_sessions() { + let sandbox = RadrootsCliSandbox::new(); + let signer = identity_public(2); + let user = identity_public(3); + let payload = ready_myc_payload(vec![remote_session("session-ping", &signer, &user, "ping")]); + let myc = write_fake_myc_status(&sandbox, "myc-no-authorized", payload); + configure_myc_mode_with_binding(&sandbox, &myc, "managed_instance", "default", None, None); + + let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + + assert_eq!(value["result"]["mode"], "myc"); + assert_eq!(value["result"]["state"], "unavailable"); + assert_eq!(value["result"]["binding"]["state"], "unavailable"); + assert_eq!(value["result"]["binding"]["matched_session_count"], 0); + assert_contains( + &value["result"]["binding"]["reason"], + "no authorized remote signer session", + ); +} + +#[cfg(unix)] +#[test] +fn myc_binding_reports_ambiguous_authorized_sessions() { + let sandbox = RadrootsCliSandbox::new(); + let signer = identity_public(4); + let user_one = identity_public(5); + let user_two = identity_public(6); + let payload = ready_myc_payload(vec![ + remote_session("session-one", &signer, &user_one, "sign_event"), + remote_session("session-two", &signer, &user_two, "sign_event"), + ]); + let myc = write_fake_myc_status(&sandbox, "myc-ambiguous", payload); + configure_myc_mode_with_binding(&sandbox, &myc, "managed_instance", "default", None, None); + + let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + + assert_eq!(value["result"]["mode"], "myc"); + assert_eq!(value["result"]["state"], "unconfigured"); + assert_eq!(value["result"]["binding"]["state"], "ambiguous"); + assert_eq!(value["result"]["binding"]["matched_session_count"], 2); + assert_contains( + &value["result"]["binding"]["reason"], + "multiple authorized remote signer sessions", + ); +} + +#[cfg(unix)] +#[test] +fn myc_binding_reports_ready_for_one_authorized_session() { + let sandbox = RadrootsCliSandbox::new(); + let signer = identity_public(7); + let user = identity_public(8); + let payload = ready_myc_payload(vec![remote_session( + "session-ready", + &signer, + &user, + "sign_event", + )]); + let myc = write_fake_myc_status(&sandbox, "myc-ready-bound", payload); + configure_myc_mode_with_binding( + &sandbox, + &myc, + "managed_instance", + "default", + Some(user.id.as_str()), + None, + ); + + let value = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); + + assert_eq!(value["result"]["mode"], "myc"); + assert_eq!(value["result"]["state"], "ready"); + assert_eq!(value["result"]["signer_account_id"], user.id.as_str()); + assert_eq!(value["result"]["binding"]["state"], "ready"); + assert_eq!( + value["result"]["binding"]["resolved_signer_session_id"], + "session-ready" + ); + assert_eq!(value["result"]["binding"]["matched_session_count"], 1); + assert_eq!( + value["result"]["write_kinds"] + .as_array() + .expect("write kinds") + .iter() + .filter(|kind| kind["ready"] == true) + .count(), + 4 + ); +} + fn configure_myc_mode(sandbox: &RadrootsCliSandbox, executable: &Path) { sandbox.write_app_config(&format!( "[signer]\nmode = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n", @@ -129,6 +242,86 @@ fn configure_myc_mode(sandbox: &RadrootsCliSandbox, executable: &Path) { )); } +fn configure_myc_mode_with_binding( + sandbox: &RadrootsCliSandbox, + executable: &Path, + target_kind: &str, + target: &str, + managed_account_ref: Option<&str>, + signer_session_ref: Option<&str>, +) { + let mut raw = format!( + "[signer]\nmode = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n\n[[capability_binding]]\ncapability = \"signer.remote_nip46\"\nprovider = \"myc\"\ntarget_kind = \"{}\"\ntarget = \"{}\"\n", + toml_string(executable.display().to_string().as_str()), + toml_string(target_kind), + toml_string(target) + ); + if let Some(value) = managed_account_ref { + raw.push_str(&format!( + "managed_account_ref = \"{}\"\n", + toml_string(value) + )); + } + if let Some(value) = signer_session_ref { + raw.push_str(&format!( + "signer_session_ref = \"{}\"\n", + toml_string(value) + )); + } + sandbox.write_app_config(raw.as_str()); +} + +#[cfg(unix)] +fn write_fake_myc_status( + sandbox: &RadrootsCliSandbox, + name: &str, + payload: Value, +) -> std::path::PathBuf { + let raw = serde_json::to_string(&payload).expect("myc status payload"); + sandbox.write_fake_myc( + name, + format!("printf '%s\\n' '{}'", shell_single_quoted(raw.as_str())).as_str(), + ) +} + +fn ready_myc_payload(remote_sessions: Vec<Value>) -> Value { + json!({ + "status_contract_version": 1, + "status": "ready", + "ready": true, + "signer_backend": { + "remote_session_count": remote_sessions.len(), + "remote_sessions": remote_sessions + } + }) +} + +fn remote_session( + connection_id: &str, + signer_identity: &RadrootsIdentityPublic, + user_identity: &RadrootsIdentityPublic, + permissions: &str, +) -> Value { + json!({ + "connection_id": connection_id, + "signer_identity": signer_identity, + "user_identity": user_identity, + "relays": ["wss://relay.example.test"], + "permissions": permissions + }) +} + +fn identity_public(seed: u8) -> RadrootsIdentityPublic { + let secret = [seed; 32]; + RadrootsIdentity::from_secret_key_bytes(&secret) + .expect("fixture identity") + .to_public() +} + +fn shell_single_quoted(value: &str) -> String { + value.replace('\'', "'\"'\"'") +} + fn toml_string(value: &str) -> String { value.replace('\\', "\\\\").replace('"', "\\\"") } diff --git a/tests/support/mod.rs b/tests/support/mod.rs @@ -3,6 +3,7 @@ use std::fs; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; +use std::sync::Mutex; use assert_cmd::prelude::*; use serde_json::Value; @@ -11,6 +12,8 @@ use tempfile::TempDir; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +static COMMAND_LOCK: Mutex<()> = Mutex::new(()); + pub fn radroots() -> Command { Command::cargo_bin("radroots").expect("binary") } @@ -47,6 +50,7 @@ impl RadrootsCliSandbox { } pub fn json_success(&self, args: &[&str]) -> Value { + let _guard = COMMAND_LOCK.lock().expect("cli command lock"); let output = self.command().args(args).output().expect("run command"); assert!( output.status.success(), @@ -58,6 +62,7 @@ impl RadrootsCliSandbox { } pub fn json_output(&self, args: &[&str]) -> (Output, Value) { + let _guard = COMMAND_LOCK.lock().expect("cli command lock"); let output = self.command().args(args).output().expect("run command"); let value = json_from_stdout(&output); (output, value)