cli

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

commit 4e6b4cb2328e14e63e0feb223ef96543e89a22db
parent 10823fc93b339d601858b416881e3d063c6d6874
Author: triesap <tyson@radroots.org>
Date:   Wed,  8 Apr 2026 18:45:27 +0000

cli: resolve signer sessions for daemon writes

Diffstat:
Msrc/runtime/daemon.rs | 87+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/runtime/listing.rs | 22++++++++++++++++++++++
Msrc/runtime/order.rs | 22++++++++++++++++++++--
Mtests/listing.rs | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mtests/order.rs | 282+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
5 files changed, 638 insertions(+), 56 deletions(-)

diff --git a/src/runtime/daemon.rs b/src/runtime/daemon.rs @@ -374,6 +374,7 @@ pub fn bridge_listing_publish( listing: &RadrootsListing, kind: u32, idempotency_key: Option<&str>, + signer_session_id: Option<&str>, ) -> Result<BridgeListingPublishResult, DaemonRpcError> { let response: BridgePublishResponseRemote = call( config, @@ -382,6 +383,7 @@ pub fn bridge_listing_publish( "listing": listing, "kind": kind, "idempotency_key": idempotency_key, + "signer_session_id": signer_session_id, })), RpcAuthMode::BridgeBearer, )?; @@ -401,6 +403,7 @@ pub fn bridge_order_request( config: &RuntimeConfig, order: &RadrootsTradeOrder, idempotency_key: Option<&str>, + signer_session_id: Option<&str>, ) -> Result<BridgeOrderRequestResult, DaemonRpcError> { let response: BridgePublishResponseRemote = call( config, @@ -408,6 +411,7 @@ pub fn bridge_order_request( Some(serde_json::json!({ "order": order, "idempotency_key": idempotency_key, + "signer_session_id": signer_session_id, })), RpcAuthMode::BridgeBearer, )?; @@ -446,6 +450,89 @@ fn nip46_sessions(config: &RuntimeConfig) -> Result<Vec<Nip46SessionRemote>, Dae call(config, "nip46.session.list", None, RpcAuthMode::None) } +pub fn resolve_signer_session_id( + config: &RuntimeConfig, + actor_role: &str, + actor_pubkey: &str, + event_kind: u32, + requested_session_id: Option<&str>, +) -> Result<String, DaemonRpcError> { + let sessions = nip46_sessions(config)?; + + if let Some(session_id) = requested_session_id { + let Some(session) = sessions + .into_iter() + .find(|session| session.session_id == session_id) + else { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{session_id}` was not found" + ))); + }; + validate_signer_session(&session, actor_role, actor_pubkey, event_kind)?; + return Ok(session.session_id); + } + + let mut matches = sessions + .into_iter() + .filter(|session| session_matches_actor(session, actor_pubkey, event_kind)) + .map(|session| session.session_id) + .collect::<Vec<_>>(); + + match matches.len() { + 1 => Ok(matches.pop().expect("exactly one signer session")), + 0 => Err(DaemonRpcError::Unconfigured(format!( + "no authorized signer session matched {actor_role} pubkey `{actor_pubkey}` for sign_event:{event_kind}; connect a signer session or pass --signer-session-id" + ))), + _ => Err(DaemonRpcError::Unconfigured(format!( + "multiple authorized signer sessions matched {actor_role} pubkey `{actor_pubkey}` for sign_event:{event_kind}; pass --signer-session-id" + ))), + } +} + +fn validate_signer_session( + session: &Nip46SessionRemote, + actor_role: &str, + actor_pubkey: &str, + event_kind: u32, +) -> Result<(), DaemonRpcError> { + if !session.authorized { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{}` is not authorized", + session.session_id + ))); + } + if !session.signer_pubkey.eq_ignore_ascii_case(actor_pubkey) { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{}` signer pubkey `{}` does not match {actor_role} pubkey `{actor_pubkey}`", + session.session_id, session.signer_pubkey + ))); + } + if !sign_event_allowed(&session.permissions, event_kind) { + return Err(DaemonRpcError::Unconfigured(format!( + "requested signer session `{}` is not approved for sign_event:{event_kind}", + session.session_id + ))); + } + Ok(()) +} + +fn session_matches_actor( + session: &Nip46SessionRemote, + actor_pubkey: &str, + event_kind: u32, +) -> bool { + session.authorized + && session.signer_pubkey.eq_ignore_ascii_case(actor_pubkey) + && sign_event_allowed(&session.permissions, event_kind) +} + +fn sign_event_allowed(perms: &[String], kind: u32) -> bool { + perms.iter().any(|entry| entry == "sign_event") + || perms + .iter() + .any(|entry| entry == &format!("sign_event:{kind}")) +} + fn call<T: DeserializeOwned>( config: &RuntimeConfig, method: &str, diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -541,11 +541,33 @@ fn mutate( }); } + let signer_session_id = match daemon::resolve_signer_session_id( + config, + "seller", + canonical.seller_pubkey.as_str(), + KIND_LISTING, + args.signer_session_id.as_deref(), + ) { + Ok(session_id) => session_id, + Err(error) => { + return Ok(daemon_error_view( + config, + args, + operation, + &canonical, + listing_addr, + event_preview, + error, + )); + } + }; + match daemon::bridge_listing_publish( config, &canonical.listing, KIND_LISTING, args.idempotency_key.as_deref(), + Some(signer_session_id.as_str()), ) { Ok(result) => { let failed = result.status == "failed"; diff --git a/src/runtime/order.rs b/src/runtime/order.rs @@ -5,7 +5,9 @@ use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use radroots_events::kinds::KIND_LISTING; -use radroots_events::trade::{RadrootsTradeOrder, RadrootsTradeOrderItem}; +use radroots_events::trade::{ + RadrootsTradeMessageType, RadrootsTradeOrder, RadrootsTradeOrderItem, +}; use radroots_events_codec::d_tag::is_d_tag_base64url; use radroots_events_codec::trade::RadrootsTradeListingAddress; use serde::{Deserialize, Serialize}; @@ -402,8 +404,24 @@ pub fn submit( }); } + let signer_session_id = match daemon::resolve_signer_session_id( + config, + "buyer", + loaded.document.order.buyer_pubkey.as_str(), + u32::from(RadrootsTradeMessageType::OrderRequest.kind()), + args.signer_session_id.as_deref(), + ) { + Ok(session_id) => session_id, + Err(error) => return Ok(order_submit_error_view(&loaded, args, error)), + }; + let order = trade_order_from_document(&loaded.document); - match daemon::bridge_order_request(config, &order, args.idempotency_key.as_deref()) { + match daemon::bridge_order_request( + config, + &order, + args.idempotency_key.as_deref(), + Some(signer_session_id.as_str()), + ) { Ok(result) => { let mut updated = loaded.document.clone(); updated.submission = Some(OrderDraftSubmission { diff --git a/tests/listing.rs b/tests/listing.rs @@ -120,9 +120,10 @@ fn listing_validate_resolves_selected_account_and_matching_farm() { serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); let seller_pubkey = account_json["public_identity"]["public_key_hex"] .as_str() - .expect("seller pubkey"); + .expect("seller pubkey") + .to_owned(); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; - seed_farm(dir.path(), seller_pubkey, farm_d_tag, "La Huerta"); + seed_farm(dir.path(), seller_pubkey.as_str(), farm_d_tag, "La Huerta"); let draft_path = dir.path().join("eggs.toml"); fs::write( @@ -283,9 +284,10 @@ fn listing_publish_and_update_use_durable_bridge_publish() { serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); let seller_pubkey = account_json["public_identity"]["public_key_hex"] .as_str() - .expect("seller pubkey"); + .expect("seller pubkey") + .to_owned(); let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; - seed_farm(dir.path(), seller_pubkey, farm_d_tag, "La Huerta"); + seed_farm(dir.path(), seller_pubkey.as_str(), farm_d_tag, "La Huerta"); let draft_path = dir.path().join("eggs.toml"); fs::write( @@ -315,12 +317,23 @@ fn listing_publish_and_update_use_durable_bridge_publish() { let recorded = Arc::clone(&requests); let server = MockRpcServer::start(move |body, auth_header| { recorded.lock().expect("recorded").push(body.clone()); - assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret")); match body["method"].as_str().unwrap_or_default() { - "bridge.listing.publish" => MockRpcResponse::success(json!({ - "deduplicated": false, - "job": sample_listing_job("job_listing_01", "published", "event_listing_01", "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg") - })), + "nip46.session.list" => { + assert_eq!(auth_header, None); + MockRpcResponse::success(json!([sample_session( + "sess_publish_01", + seller_pubkey.as_str(), + &["sign_event"], + true + )])) + } + "bridge.listing.publish" => { + assert_eq!(auth_header.as_deref(), Some("Bearer bridge-secret")); + MockRpcResponse::success(json!({ + "deduplicated": false, + "job": sample_listing_job("job_listing_01", "published", "event_listing_01", "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg") + })) + } other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), } }); @@ -377,10 +390,20 @@ fn listing_publish_and_update_use_durable_bridge_publish() { assert_eq!(update_json["operation"], "update"); let recorded = requests.lock().expect("requests"); - assert_eq!(recorded.len(), 2); - assert_eq!(recorded[0]["params"]["kind"], 30402); - assert_eq!(recorded[0]["params"]["idempotency_key"], "publish-key"); + assert_eq!(recorded.len(), 4); + assert_eq!(recorded[0]["method"], "nip46.session.list"); assert_eq!(recorded[1]["params"]["kind"], 30402); + assert_eq!(recorded[1]["params"]["idempotency_key"], "publish-key"); + assert_eq!( + recorded[1]["params"]["signer_session_id"], + "sess_publish_01" + ); + assert_eq!(recorded[2]["method"], "nip46.session.list"); + assert_eq!(recorded[3]["params"]["kind"], 30402); + assert_eq!( + recorded[3]["params"]["signer_session_id"], + "sess_publish_01" + ); } #[test] @@ -402,10 +425,11 @@ fn listing_archive_and_dry_run_are_truthful() { serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); let seller_pubkey = account_json["public_identity"]["public_key_hex"] .as_str() - .expect("seller pubkey"); + .expect("seller pubkey") + .to_owned(); seed_farm( dir.path(), - seller_pubkey, + seller_pubkey.as_str(), "AAAAAAAAAAAAAAAAAAAAAw", "La Huerta", ); @@ -438,10 +462,19 @@ fn listing_archive_and_dry_run_are_truthful() { let recorded = Arc::clone(&requests); let server = MockRpcServer::start(move |body, _auth_header| { recorded.lock().expect("recorded").push(body.to_string()); - MockRpcResponse::success(json!({ - "deduplicated": false, - "job": sample_listing_job("job_listing_archive", "published", "event_listing_archive", "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg") - })) + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([sample_session( + "sess_archive_01", + seller_pubkey.as_str(), + &["sign_event"], + true + )])), + "bridge.listing.publish" => MockRpcResponse::success(json!({ + "deduplicated": false, + "job": sample_listing_job("job_listing_archive", "published", "event_listing_archive", "30402:deadbeef:AAAAAAAAAAAAAAAAAAAAAg") + })), + other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), + } }); let archive_output = cli_command_in(dir.path()) @@ -499,8 +532,198 @@ fn listing_archive_and_dry_run_are_truthful() { ); let recorded = requests.lock().expect("requests"); + assert_eq!(recorded.len(), 2); + assert!(recorded[1].contains("archived")); +} + +#[test] +fn listing_publish_without_matching_signer_session_exits_unconfigured() { + let _guard = listing_test_guard(); + let dir = tempdir().expect("tempdir"); + let init = cli_command_in(dir.path()) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(init.status.success()); + + let account_output = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + let account_json: Value = + serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); + let seller_pubkey = account_json["public_identity"]["public_key_hex"] + .as_str() + .expect("seller pubkey") + .to_owned(); + seed_farm( + dir.path(), + seller_pubkey.as_str(), + "AAAAAAAAAAAAAAAAAAAAAw", + "La Huerta", + ); + + let draft_path = dir.path().join("no-session.toml"); + fs::write( + &draft_path, + valid_listing_draft( + "AAAAAAAAAAAAAAAAAAAAAg", + "", + "", + "eggs", + "Pasture eggs", + "Protein", + "Fresh pasture-raised eggs collected daily.", + "12", + "each", + "4.50", + "USD", + "1", + "each", + "18", + "pickup", + "La Huerta del Sur", + ), + ) + .expect("write listing draft"); + + let requests = Arc::new(Mutex::new(Vec::<Value>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, _auth_header| { + recorded.lock().expect("recorded").push(body.clone()); + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([sample_session( + "sess_other_01", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + &["sign_event"], + true + )])), + other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), + } + }); + + let publish_output = cli_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") + .args([ + "--json", + "listing", + "publish", + draft_path.to_str().expect("draft path"), + ]) + .output() + .expect("run listing publish"); + assert_eq!(publish_output.status.code(), Some(3)); + let publish_json: Value = + serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); + assert_eq!(publish_json["state"], "unconfigured"); + assert!( + publish_json["reason"] + .as_str() + .expect("reason") + .contains("no authorized signer session matched seller pubkey") + ); + + let recorded = requests.lock().expect("requests"); assert_eq!(recorded.len(), 1); - assert!(recorded[0].contains("archived")); + assert_eq!(recorded[0]["method"], "nip46.session.list"); +} + +#[test] +fn listing_publish_rejects_requested_session_that_mismatches_seller_pubkey() { + let _guard = listing_test_guard(); + let dir = tempdir().expect("tempdir"); + let init = cli_command_in(dir.path()) + .args(["local", "init"]) + .output() + .expect("run local init"); + assert!(init.status.success()); + + let account_output = cli_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + let account_json: Value = + serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); + let seller_pubkey = account_json["public_identity"]["public_key_hex"] + .as_str() + .expect("seller pubkey") + .to_owned(); + seed_farm( + dir.path(), + seller_pubkey.as_str(), + "AAAAAAAAAAAAAAAAAAAAAw", + "La Huerta", + ); + + let draft_path = dir.path().join("mismatch-session.toml"); + fs::write( + &draft_path, + valid_listing_draft( + "AAAAAAAAAAAAAAAAAAAAAg", + "", + "", + "eggs", + "Pasture eggs", + "Protein", + "Fresh pasture-raised eggs collected daily.", + "12", + "each", + "4.50", + "USD", + "1", + "each", + "18", + "pickup", + "La Huerta del Sur", + ), + ) + .expect("write listing draft"); + + let requests = Arc::new(Mutex::new(Vec::<Value>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, _auth_header| { + recorded.lock().expect("recorded").push(body.clone()); + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([sample_session( + "sess_wrong_01", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + &["sign_event"], + true + )])), + other => MockRpcResponse::rpc_error(-32601, &format!("unexpected method: {other}")), + } + }); + + let publish_output = cli_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .env("RADROOTS_RPC_BEARER_TOKEN", "bridge-secret") + .args([ + "--json", + "listing", + "publish", + "--signer-session-id", + "sess_wrong_01", + draft_path.to_str().expect("draft path"), + ]) + .output() + .expect("run listing publish"); + assert_eq!(publish_output.status.code(), Some(3)); + let publish_json: Value = + serde_json::from_slice(publish_output.stdout.as_slice()).expect("publish json"); + assert_eq!(publish_json["state"], "unconfigured"); + assert!( + publish_json["reason"] + .as_str() + .expect("reason") + .contains("does not match seller pubkey") + ); + + let recorded = requests.lock().expect("requests"); + assert_eq!(recorded.len(), 1); + assert_eq!(recorded[0]["method"], "nip46.session.list"); } fn seed_farm(workdir: &Path, pubkey: &str, d_tag: &str, name: &str) { @@ -747,6 +970,26 @@ fn sample_listing_job(job_id: &str, status: &str, event_id: &str, event_addr: &s }) } +fn sample_session( + session_id: &str, + signer_pubkey: &str, + permissions: &[&str], + authorized: bool, +) -> Value { + json!({ + "session_id": session_id, + "role": "remote_signer", + "client_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "signer_pubkey": signer_pubkey, + "user_pubkey": Value::Null, + "relays": ["wss://relay.one"], + "permissions": permissions, + "auth_required": false, + "authorized": authorized, + "expires_in_secs": Value::Null + }) +} + fn seed_trade_product( workdir: &Path, product_id: &str, diff --git a/tests/order.rs b/tests/order.rs @@ -9,7 +9,7 @@ use std::thread::{self, JoinHandle}; use std::time::Duration; use assert_cmd::prelude::*; -use serde_json::Value; +use serde_json::{Value, json}; use tempfile::tempdir; fn data_root(workdir: &Path) -> std::path::PathBuf { @@ -61,6 +61,7 @@ fn order_test_guard() -> MutexGuard<'static, ()> { #[derive(Debug, Clone)] struct MockRpcRequest { + body: Value, method: String, auth_header: Option<String>, } @@ -91,7 +92,7 @@ struct MockRpcServer { impl MockRpcServer { fn start<F>(handler: F) -> Self where - F: Fn(String, Option<String>) -> MockRpcResponse + Send + Sync + 'static, + F: Fn(Value, Option<String>) -> MockRpcResponse + Send + Sync + 'static, { let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock rpc listener"); listener @@ -103,7 +104,7 @@ impl MockRpcServer { .to_string(); let shutdown = Arc::new(AtomicBool::new(false)); let shutdown_flag = Arc::clone(&shutdown); - let handler: Arc<dyn Fn(String, Option<String>) -> MockRpcResponse + Send + Sync> = + let handler: Arc<dyn Fn(Value, Option<String>) -> MockRpcResponse + Send + Sync> = Arc::new(handler); let handle = thread::spawn(move || { while !shutdown_flag.load(Ordering::SeqCst) { @@ -111,7 +112,7 @@ impl MockRpcServer { Ok((mut stream, _)) => { if let Ok(request) = read_request(&mut stream) { let response = - handler(request.method.clone(), request.auth_header.clone()); + handler(request.body.clone(), request.auth_header.clone()); let _ = write_response(&mut stream, &response); } } @@ -192,6 +193,7 @@ fn read_request(stream: &mut TcpStream) -> Result<MockRpcRequest, String> { .to_owned(); Ok(MockRpcRequest { + body: envelope, method, auth_header, }) @@ -450,28 +452,6 @@ job_id = "job_order_01" fn order_submit_persists_submission_metadata_and_reports_job() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir"); - let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); - let recorded = Arc::clone(&requests); - let server = MockRpcServer::start(move |method, auth_header| { - recorded - .lock() - .expect("recorded requests lock") - .push(MockRpcRequest { - method: method.clone(), - auth_header, - }); - match method.as_str() { - "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ - "deduplicated": false, - "job": sample_bridge_job("job_order_01", "accepted", false), - })), - "bridge.job.status" => { - MockRpcResponse::success(sample_bridge_job("job_order_01", "accepted", false)) - } - other => panic!("unexpected mock rpc method {other}"), - } - }); - let account_output = order_command_in(dir.path()) .args(["--json", "account", "new"]) .output() @@ -496,6 +476,39 @@ fn order_submit_persists_submission_metadata_and_reports_job() { let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); let order_id = new_json["order_id"].as_str().expect("order id"); let file = new_json["file"].as_str().expect("file"); + let buyer_pubkey = new_json["buyer_pubkey"] + .as_str() + .expect("buyer pubkey") + .to_owned(); + + let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, auth_header| { + recorded + .lock() + .expect("recorded requests lock") + .push(MockRpcRequest { + body: body.clone(), + method: body["method"].as_str().unwrap_or_default().to_owned(), + auth_header, + }); + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([sample_session( + "sess_order_01", + buyer_pubkey.as_str(), + &["sign_event"], + true + )])), + "bridge.order.request" => MockRpcResponse::success(serde_json::json!({ + "deduplicated": false, + "job": sample_bridge_job("job_order_01", "accepted", false), + })), + "bridge.job.status" => { + MockRpcResponse::success(sample_bridge_job("job_order_01", "accepted", false)) + } + other => panic!("unexpected mock rpc method {other}"), + } + }); let submit_output = order_command_in(dir.path()) .env("RADROOTS_RPC_URL", server.url()) @@ -538,6 +551,16 @@ fn order_submit_persists_submission_metadata_and_reports_job() { assert!( recorded_requests .iter() + .any(|request| request.method == "nip46.session.list") + ); + let request = recorded_requests + .iter() + .find(|request| request.method == "bridge.order.request") + .expect("bridge order request"); + assert_eq!(request.body["params"]["signer_session_id"], "sess_order_01"); + assert!( + recorded_requests + .iter() .any(|request| { request.auth_header.as_deref() == Some("Bearer test-token") }) ); } @@ -548,17 +571,19 @@ fn order_watch_reports_job_frames_for_submitted_order() { let dir = tempdir().expect("tempdir"); let polls = Arc::new(Mutex::new(0usize)); let watch_polls = Arc::clone(&polls); - let server = MockRpcServer::start(move |method, _auth_header| match method.as_str() { - "bridge.job.status" => { - let mut count = watch_polls.lock().expect("watch polls lock"); - *count += 1; - if *count == 1 { - MockRpcResponse::success(sample_bridge_job("job_watch_01", "accepted", false)) - } else { - MockRpcResponse::success(sample_bridge_job("job_watch_01", "completed", true)) + let server = MockRpcServer::start(move |body, _auth_header| { + match body["method"].as_str().unwrap_or_default() { + "bridge.job.status" => { + let mut count = watch_polls.lock().expect("watch polls lock"); + *count += 1; + if *count == 1 { + MockRpcResponse::success(sample_bridge_job("job_watch_01", "accepted", false)) + } else { + MockRpcResponse::success(sample_bridge_job("job_watch_01", "completed", true)) + } } + other => panic!("unexpected mock rpc method {other}"), } - other => panic!("unexpected mock rpc method {other}"), }); let drafts_dir = data_root(dir.path()).join("apps/cli/orders/drafts"); @@ -610,6 +635,193 @@ job_id = "job_watch_01" } #[test] +fn order_submit_without_unique_matching_signer_session_exits_unconfigured() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + + let account_output = order_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + let account_json: Value = + serde_json::from_slice(account_output.stdout.as_slice()).expect("account json"); + let buyer_pubkey = account_json["public_identity"]["public_key_hex"] + .as_str() + .expect("buyer pubkey") + .to_owned(); + + let new_output = order_command_in(dir.path()) + .args([ + "--json", + "order", + "new", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + ]) + .output() + .expect("run order new"); + assert!(new_output.status.success()); + let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); + let order_id = new_json["order_id"].as_str().expect("order id"); + + let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, _auth_header| { + recorded + .lock() + .expect("recorded requests lock") + .push(MockRpcRequest { + body: body.clone(), + method: body["method"].as_str().unwrap_or_default().to_owned(), + auth_header: None, + }); + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([ + sample_session( + "sess_order_01", + buyer_pubkey.as_str(), + &["sign_event"], + true + ), + sample_session( + "sess_order_02", + buyer_pubkey.as_str(), + &["sign_event"], + true + ) + ])), + other => panic!("unexpected mock rpc method {other}"), + } + }); + + let submit_output = order_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") + .args(["--json", "order", "submit", order_id]) + .output() + .expect("run order submit"); + assert_eq!(submit_output.status.code(), Some(3)); + let submit_json: Value = + serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); + assert_eq!(submit_json["state"], "unconfigured"); + assert!( + submit_json["reason"] + .as_str() + .expect("reason") + .contains("multiple authorized signer sessions matched buyer pubkey") + ); + + let recorded_requests = requests.lock().expect("requests lock"); + assert_eq!(recorded_requests.len(), 1); + assert_eq!(recorded_requests[0].method, "nip46.session.list"); +} + +#[test] +fn order_submit_rejects_requested_session_that_mismatches_buyer_pubkey() { + let _guard = order_test_guard(); + let dir = tempdir().expect("tempdir"); + + let account_output = order_command_in(dir.path()) + .args(["--json", "account", "new"]) + .output() + .expect("run account new"); + assert!(account_output.status.success()); + + let new_output = order_command_in(dir.path()) + .args([ + "--json", + "order", + "new", + "--listing", + "pasture-eggs", + "--listing-addr", + "30402:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef:AAAAAAAAAAAAAAAAAAAAAg", + "--bin", + "bin-1", + ]) + .output() + .expect("run order new"); + assert!(new_output.status.success()); + let new_json: Value = serde_json::from_slice(new_output.stdout.as_slice()).expect("new json"); + let order_id = new_json["order_id"].as_str().expect("order id"); + + let requests = Arc::new(Mutex::new(Vec::<MockRpcRequest>::new())); + let recorded = Arc::clone(&requests); + let server = MockRpcServer::start(move |body, _auth_header| { + recorded + .lock() + .expect("recorded requests lock") + .push(MockRpcRequest { + body: body.clone(), + method: body["method"].as_str().unwrap_or_default().to_owned(), + auth_header: None, + }); + match body["method"].as_str().unwrap_or_default() { + "nip46.session.list" => MockRpcResponse::success(json!([sample_session( + "sess_wrong_01", + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + &["sign_event"], + true + )])), + other => panic!("unexpected mock rpc method {other}"), + } + }); + + let submit_output = order_command_in(dir.path()) + .env("RADROOTS_RPC_URL", server.url()) + .env("RADROOTS_RPC_BEARER_TOKEN", "test-token") + .args([ + "--json", + "order", + "submit", + order_id, + "--signer-session-id", + "sess_wrong_01", + ]) + .output() + .expect("run order submit"); + assert_eq!(submit_output.status.code(), Some(3)); + let submit_json: Value = + serde_json::from_slice(submit_output.stdout.as_slice()).expect("submit json"); + assert_eq!(submit_json["state"], "unconfigured"); + assert!( + submit_json["reason"] + .as_str() + .expect("reason") + .contains("does not match buyer pubkey") + ); + + let recorded_requests = requests.lock().expect("requests lock"); + assert_eq!(recorded_requests.len(), 1); + assert_eq!(recorded_requests[0].method, "nip46.session.list"); +} + +fn sample_session( + session_id: &str, + signer_pubkey: &str, + permissions: &[&str], + authorized: bool, +) -> Value { + json!({ + "session_id": session_id, + "role": "remote_signer", + "client_pubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "signer_pubkey": signer_pubkey, + "user_pubkey": Value::Null, + "relays": ["wss://relay.one"], + "permissions": permissions, + "auth_required": false, + "authorized": authorized, + "expires_in_secs": Value::Null + }) +} + +#[test] fn order_history_lists_submitted_order_drafts() { let _guard = order_test_guard(); let dir = tempdir().expect("tempdir");