target_cli.rs (269510B)
1 mod support; 2 3 use std::fs; 4 use std::io::{Read, Write}; 5 use std::net::{TcpListener, TcpStream}; 6 use std::path::{Path, PathBuf}; 7 use std::sync::{ 8 Arc, Mutex, 9 atomic::{AtomicBool, Ordering}, 10 }; 11 use std::thread::{self, JoinHandle}; 12 use std::time::{Duration, Instant}; 13 14 use nostr::nips::nip44::{self, Version}; 15 use nostr::{EventBuilder, Keys, Kind, PublicKey, SecretKey, Tag}; 16 use radroots_events::RadrootsNostrEventPtr; 17 use radroots_events::ids::{ 18 RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, RadrootsPublicKey, 19 }; 20 use radroots_events::kinds::{KIND_LISTING, KIND_ORDER_REQUEST}; 21 use radroots_events::order::{RadrootsOrderEconomics, RadrootsOrderItem, RadrootsOrderRequest}; 22 use radroots_events_codec::order::order_request_event_build; 23 use radroots_identity::RadrootsIdentity; 24 use radroots_local_events::{ 25 BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecordInput, LocalEventsStore, 26 LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, RelayDeliveryEvidence, 27 SourceRuntime, canonical_relay_set_fingerprint, 28 }; 29 use radroots_nostr::prelude::{RadrootsNostrEvent, radroots_nostr_build_event}; 30 use radroots_nostr_connect::prelude::{ 31 RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectRequest, 32 RadrootsNostrConnectRequestMessage, RadrootsNostrConnectResponse, 33 }; 34 use radroots_replica_db::{farm, migrations}; 35 use radroots_replica_db_schema::farm::IFarmFields; 36 use radroots_replica_sync::radroots_replica_pending_publish_batch; 37 use radroots_sql_core::SqliteExecutor; 38 use serde_json::Value; 39 use serde_json::json; 40 41 use support::{ 42 ORDERABLE_LISTING_RELAY, RadrootsCliSandbox, assert_contains, 43 assert_no_daemon_runtime_reference, assert_no_removed_command_reference, create_listing_draft, 44 duplicate_orderable_listing_row, identity_public, identity_secret, json_from_stdout, 45 make_listing_publishable, ndjson_from_stdout, radroots, remove_orderable_listing, 46 replace_latest_listing_event_id, seed_orderable_listing, store_test_session_secret, 47 toml_string, update_orderable_listing_available_amount, 48 update_orderable_listing_primary_bin_id, write_public_identity_profile, 49 write_secret_identity_profile, 50 }; 51 52 const LISTING_ADDR: &str = 53 "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; 54 const LEGACY_SYNC_PUSH_FARM_D_TAG: &str = "AAAAAAAAAAAAAAAAAAAAAA"; 55 56 fn test_order_id(value: &str) -> RadrootsOrderId { 57 value.parse().expect("valid order id") 58 } 59 60 fn test_listing_addr(value: &str) -> RadrootsListingAddress { 61 value.parse().expect("valid listing address") 62 } 63 64 fn test_inventory_bin_id(value: &str) -> RadrootsInventoryBinId { 65 value.parse().expect("valid inventory bin id") 66 } 67 68 fn test_pubkey(value: &str) -> RadrootsPublicKey { 69 value.parse().expect("valid public key") 70 } 71 72 fn radrootsd_proxy_token_file(sandbox: &RadrootsCliSandbox) -> PathBuf { 73 let path = sandbox.root().join("radrootsd_proxy.token"); 74 fs::write(&path, "proxy_test_token\n").expect("write proxy token file"); 75 path 76 } 77 78 struct RelayFetchServer { 79 endpoint: String, 80 handle: JoinHandle<()>, 81 } 82 83 impl RelayFetchServer { 84 fn with_events(events: Vec<RadrootsNostrEvent>) -> Self { 85 let listener = TcpListener::bind("127.0.0.1:0").expect("bind relay fetch"); 86 let endpoint = format!("ws://{}", listener.local_addr().expect("relay fetch addr")); 87 let handle = thread::spawn(move || { 88 let (stream, _) = listener.accept().expect("accept relay fetch connection"); 89 handle_relay_fetch_connection(stream, events); 90 }); 91 Self { endpoint, handle } 92 } 93 94 fn endpoint(&self) -> &str { 95 self.endpoint.as_str() 96 } 97 98 fn join(self) { 99 self.handle.join().expect("relay fetch server join"); 100 } 101 } 102 103 struct RadrootsdProxyJsonRpcServer { 104 endpoint: String, 105 handle: JoinHandle<Value>, 106 } 107 108 impl RadrootsdProxyJsonRpcServer { 109 fn once(expected_token: &'static str) -> Self { 110 let listener = TcpListener::bind("127.0.0.1:0").expect("bind radrootsd proxy"); 111 listener 112 .set_nonblocking(true) 113 .expect("radrootsd proxy nonblocking"); 114 let endpoint = format!("http://{}", listener.local_addr().expect("proxy addr")); 115 let handle = thread::spawn(move || { 116 let deadline = Instant::now() + Duration::from_secs(10); 117 loop { 118 match listener.accept() { 119 Ok((stream, _)) => { 120 return handle_radrootsd_proxy_connection(stream, expected_token); 121 } 122 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { 123 assert!( 124 Instant::now() < deadline, 125 "timed out waiting for radrootsd proxy request" 126 ); 127 thread::sleep(Duration::from_millis(10)); 128 } 129 Err(error) => panic!("accept radrootsd proxy connection: {error}"), 130 } 131 } 132 }); 133 Self { endpoint, handle } 134 } 135 136 fn endpoint(&self) -> &str { 137 self.endpoint.as_str() 138 } 139 140 fn join(self) -> Value { 141 self.handle.join().expect("radrootsd proxy server join") 142 } 143 } 144 145 #[derive(Clone, Copy)] 146 enum Nip46RelayFinish { 147 SignResponse, 148 ProductPublish, 149 } 150 151 struct Nip46RelayReport { 152 connection_count: usize, 153 req_count: usize, 154 sign_request_count: usize, 155 published_events: Vec<RadrootsNostrEvent>, 156 } 157 158 struct Nip46RelayState { 159 connection_count: Mutex<usize>, 160 req_count: Mutex<usize>, 161 sign_request_count: Mutex<usize>, 162 published_events: Mutex<Vec<RadrootsNostrEvent>>, 163 pending_responses: Mutex<Vec<RadrootsNostrEvent>>, 164 done: AtomicBool, 165 finish: Nip46RelayFinish, 166 } 167 168 struct Nip46RelayServer { 169 endpoint: String, 170 state: Arc<Nip46RelayState>, 171 handle: JoinHandle<()>, 172 } 173 174 impl Nip46RelayServer { 175 fn new(remote_keys: Keys, user_keys: Keys, finish: Nip46RelayFinish) -> Self { 176 let listener = TcpListener::bind("127.0.0.1:0").expect("bind nip46 relay"); 177 listener.set_nonblocking(true).expect("nip46 nonblocking"); 178 let endpoint = format!("ws://{}", listener.local_addr().expect("nip46 addr")); 179 let state = Arc::new(Nip46RelayState { 180 connection_count: Mutex::new(0), 181 req_count: Mutex::new(0), 182 sign_request_count: Mutex::new(0), 183 published_events: Mutex::new(Vec::new()), 184 pending_responses: Mutex::new(Vec::new()), 185 done: AtomicBool::new(false), 186 finish, 187 }); 188 let thread_state = Arc::clone(&state); 189 let remote_keys = Arc::new(remote_keys); 190 let user_keys = Arc::new(user_keys); 191 let handle = thread::spawn(move || { 192 let deadline = Instant::now() + Duration::from_secs(10); 193 while Instant::now() < deadline && !thread_state.done.load(Ordering::SeqCst) { 194 match listener.accept() { 195 Ok((stream, _)) => { 196 *thread_state 197 .connection_count 198 .lock() 199 .expect("connection count lock") += 1; 200 let connection_state = Arc::clone(&thread_state); 201 let remote_keys = Arc::clone(&remote_keys); 202 let user_keys = Arc::clone(&user_keys); 203 thread::spawn(move || { 204 handle_nip46_relay_connection( 205 stream, 206 (*remote_keys).clone(), 207 (*user_keys).clone(), 208 connection_state, 209 ); 210 }); 211 } 212 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { 213 thread::sleep(Duration::from_millis(10)); 214 } 215 Err(error) => panic!("accept nip46 relay connection: {error}"), 216 } 217 } 218 assert!( 219 thread_state.done.load(Ordering::SeqCst), 220 "timed out waiting for NIP-46 relay proof; connections {}; req {}; sign requests {}; published events {}", 221 *thread_state 222 .connection_count 223 .lock() 224 .expect("connection count lock"), 225 *thread_state.req_count.lock().expect("req count lock"), 226 *thread_state 227 .sign_request_count 228 .lock() 229 .expect("sign count lock"), 230 thread_state 231 .published_events 232 .lock() 233 .expect("published events lock") 234 .len(), 235 ); 236 }); 237 Self { 238 endpoint, 239 state, 240 handle, 241 } 242 } 243 244 fn endpoint(&self) -> &str { 245 self.endpoint.as_str() 246 } 247 248 fn join(self) -> Nip46RelayReport { 249 self.handle.join().expect("nip46 relay server join"); 250 Nip46RelayReport { 251 connection_count: *self 252 .state 253 .connection_count 254 .lock() 255 .expect("connection count lock"), 256 req_count: *self.state.req_count.lock().expect("req count lock"), 257 sign_request_count: *self 258 .state 259 .sign_request_count 260 .lock() 261 .expect("sign count lock"), 262 published_events: self 263 .state 264 .published_events 265 .lock() 266 .expect("published events lock") 267 .clone(), 268 } 269 } 270 } 271 272 fn handle_nip46_relay_connection( 273 stream: TcpStream, 274 remote_keys: Keys, 275 user_keys: Keys, 276 state: Arc<Nip46RelayState>, 277 ) { 278 stream 279 .set_nonblocking(false) 280 .expect("nip46 blocking stream"); 281 let mut websocket = tungstenite::accept(stream).expect("accept nip46 websocket"); 282 let mut subscriptions = Vec::<String>::new(); 283 loop { 284 let message = match websocket.read() { 285 Ok(message) => message, 286 Err(_) => return, 287 }; 288 if !message.is_text() { 289 continue; 290 } 291 let value: Value = 292 serde_json::from_str(message.to_text().expect("nip46 text")).expect("nip46 json"); 293 match value.get(0).and_then(Value::as_str) { 294 Some("REQ") => { 295 *state.req_count.lock().expect("req count lock") += 1; 296 let subscription_id = value 297 .get(1) 298 .and_then(Value::as_str) 299 .expect("nip46 subscription id") 300 .to_owned(); 301 subscriptions.push(subscription_id.clone()); 302 websocket 303 .send(tungstenite::Message::Text( 304 json!(["EOSE", subscription_id]).to_string().into(), 305 )) 306 .expect("send nip46 eose"); 307 send_pending_nip46_responses(&mut websocket, subscription_id.as_str(), &state); 308 } 309 Some("CLOSE") => {} 310 Some("EVENT") => { 311 let event: RadrootsNostrEvent = 312 serde_json::from_value(value.get(1).cloned().expect("nip46 relay event")) 313 .expect("parse nip46 relay event"); 314 if event.kind == Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND) { 315 handle_nip46_sign_request( 316 &mut websocket, 317 &subscriptions, 318 event, 319 &remote_keys, 320 &user_keys, 321 &state, 322 ); 323 } else { 324 handle_nip46_product_publish(&mut websocket, event, &state); 325 } 326 } 327 _ => {} 328 } 329 } 330 } 331 332 fn handle_nip46_sign_request( 333 websocket: &mut tungstenite::WebSocket<TcpStream>, 334 subscriptions: &[String], 335 event: RadrootsNostrEvent, 336 remote_keys: &Keys, 337 user_keys: &Keys, 338 state: &Nip46RelayState, 339 ) { 340 send_relay_ok(websocket, &event); 341 let decrypted = nip44::decrypt(remote_keys.secret_key(), &event.pubkey, &event.content) 342 .expect("decrypt nip46 request"); 343 let request: RadrootsNostrConnectRequestMessage = 344 serde_json::from_str(&decrypted).expect("decode nip46 request"); 345 let response = match request.request { 346 RadrootsNostrConnectRequest::SignEvent(unsigned_event) => { 347 let signed = unsigned_event 348 .sign_with_keys(user_keys) 349 .expect("sign nip46 request"); 350 RadrootsNostrConnectResponse::SignedEvent(signed) 351 } 352 other => RadrootsNostrConnectResponse::Error { 353 result: None, 354 error: format!("unexpected test NIP-46 method `{}`", other.method()), 355 }, 356 }; 357 let response_event = 358 nip46_response_event(remote_keys, event.pubkey, request.id.as_str(), response); 359 state 360 .pending_responses 361 .lock() 362 .expect("pending response lock") 363 .push(response_event.clone()); 364 for subscription_id in subscriptions { 365 send_nip46_response(websocket, subscription_id.as_str(), &response_event); 366 } 367 *state 368 .sign_request_count 369 .lock() 370 .expect("sign request count lock") += 1; 371 if matches!(state.finish, Nip46RelayFinish::SignResponse) { 372 state.done.store(true, Ordering::SeqCst); 373 } 374 } 375 376 fn send_pending_nip46_responses( 377 websocket: &mut tungstenite::WebSocket<TcpStream>, 378 subscription_id: &str, 379 state: &Nip46RelayState, 380 ) { 381 let responses = state 382 .pending_responses 383 .lock() 384 .expect("pending response lock") 385 .clone(); 386 for response in responses { 387 send_nip46_response(websocket, subscription_id, &response); 388 } 389 } 390 391 fn send_nip46_response( 392 websocket: &mut tungstenite::WebSocket<TcpStream>, 393 subscription_id: &str, 394 response_event: &RadrootsNostrEvent, 395 ) { 396 websocket 397 .send(tungstenite::Message::Text( 398 json!(["EVENT", subscription_id, response_event]) 399 .to_string() 400 .into(), 401 )) 402 .expect("send nip46 response event"); 403 } 404 405 fn handle_nip46_product_publish( 406 websocket: &mut tungstenite::WebSocket<TcpStream>, 407 event: RadrootsNostrEvent, 408 state: &Nip46RelayState, 409 ) { 410 send_relay_ok(websocket, &event); 411 state 412 .published_events 413 .lock() 414 .expect("published events lock") 415 .push(event); 416 if matches!(state.finish, Nip46RelayFinish::ProductPublish) { 417 state.done.store(true, Ordering::SeqCst); 418 } 419 } 420 421 fn nip46_response_event( 422 remote_keys: &Keys, 423 client_public_key: PublicKey, 424 request_id: &str, 425 response: RadrootsNostrConnectResponse, 426 ) -> RadrootsNostrEvent { 427 let envelope = response 428 .into_envelope(request_id) 429 .expect("nip46 response envelope"); 430 let payload = serde_json::to_string(&envelope).expect("nip46 response payload"); 431 let ciphertext = nip44::encrypt( 432 remote_keys.secret_key(), 433 &client_public_key, 434 payload, 435 Version::V2, 436 ) 437 .expect("nip46 response ciphertext"); 438 EventBuilder::new(Kind::Custom(RADROOTS_NOSTR_CONNECT_RPC_KIND), ciphertext) 439 .tag(Tag::public_key(client_public_key)) 440 .sign_with_keys(remote_keys) 441 .expect("nip46 response event") 442 } 443 444 fn send_relay_ok(websocket: &mut tungstenite::WebSocket<TcpStream>, event: &RadrootsNostrEvent) { 445 websocket 446 .send(tungstenite::Message::Text( 447 json!(["OK", event.id.to_hex(), true, ""]) 448 .to_string() 449 .into(), 450 )) 451 .expect("send relay ok"); 452 } 453 454 fn nostr_keys_from_identity(identity: &RadrootsIdentity) -> Keys { 455 let secret_key_hex = identity.secret_key_hex(); 456 Keys::new(SecretKey::from_hex(secret_key_hex.as_str()).expect("secret key")) 457 } 458 459 fn myc_nip46_config( 460 remote_signer_pubkey: &str, 461 relay_endpoint: &str, 462 managed_account_ref: &str, 463 session_ref: &str, 464 ) -> String { 465 format!( 466 r#"[signer] 467 backend = "myc" 468 469 [[capability_binding]] 470 capability = "signer.remote_nip46" 471 provider = "myc" 472 target_kind = "explicit_endpoint" 473 target = "bunker://{}?relay={}" 474 managed_account_ref = "{}" 475 signer_session_ref = "{}" 476 "#, 477 toml_string(remote_signer_pubkey), 478 toml_string(relay_endpoint), 479 toml_string(managed_account_ref), 480 toml_string(session_ref), 481 ) 482 } 483 484 fn handle_radrootsd_proxy_connection(mut stream: TcpStream, expected_token: &str) -> Value { 485 let mut bytes = Vec::new(); 486 let mut buffer = [0_u8; 1024]; 487 let body_start = loop { 488 let read = stream.read(&mut buffer).expect("read radrootsd proxy"); 489 assert!(read > 0, "radrootsd proxy request closed before headers"); 490 bytes.extend_from_slice(&buffer[..read]); 491 if let Some(index) = http_body_start(&bytes) { 492 break index; 493 } 494 }; 495 let headers = String::from_utf8(bytes[..body_start].to_vec()).expect("headers utf8"); 496 let content_length = http_content_length(headers.as_str()); 497 while bytes.len() < body_start + content_length { 498 let read = stream.read(&mut buffer).expect("read radrootsd proxy body"); 499 assert!(read > 0, "radrootsd proxy request closed before body"); 500 bytes.extend_from_slice(&buffer[..read]); 501 } 502 let body = String::from_utf8(bytes[body_start..body_start + content_length].to_vec()) 503 .expect("body utf8"); 504 assert!( 505 headers 506 .to_ascii_lowercase() 507 .contains(format!("authorization: bearer {expected_token}").as_str()), 508 "radrootsd proxy request missing expected bearer auth: {headers}" 509 ); 510 let request: Value = serde_json::from_str(body.as_str()).expect("radrootsd proxy json"); 511 assert_eq!(request["jsonrpc"], "2.0"); 512 assert_eq!(request["method"], "publish.event"); 513 let event = &request["params"]["event"]; 514 let relays = request["params"]["relays"] 515 .as_array() 516 .cloned() 517 .unwrap_or_default(); 518 let relay_results = relays 519 .iter() 520 .map(|relay| { 521 json!({ 522 "relay_url": relay, 523 "source": "request", 524 "attempted": true, 525 "outcome_kind": "accepted" 526 }) 527 }) 528 .collect::<Vec<_>>(); 529 let response = json!({ 530 "jsonrpc": "2.0", 531 "id": request["id"], 532 "result": { 533 "deduplicated": false, 534 "job": { 535 "job_id": "cli-proxy-job-1", 536 "status": "delivery_satisfied", 537 "terminal": true, 538 "delivery_satisfied": true, 539 "event_id": event["id"], 540 "pubkey": event["pubkey"], 541 "event_kind": event["kind"], 542 "relay_policy": request["params"]["relay_policy"], 543 "delivery_policy": request["params"]["delivery_policy"], 544 "relay_count": relay_results.len(), 545 "acknowledged_count": relay_results.len(), 546 "retryable_count": 0, 547 "terminal_count": 0, 548 "requested_at_ms": 1_700_000_000_000_i64, 549 "completed_at_ms": 1_700_000_000_001_i64, 550 "relays": relay_results 551 } 552 } 553 }); 554 let response_body = response.to_string(); 555 let raw_response = format!( 556 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response_body}", 557 response_body.len() 558 ); 559 stream 560 .write_all(raw_response.as_bytes()) 561 .expect("write radrootsd proxy response"); 562 request 563 } 564 565 fn http_body_start(bytes: &[u8]) -> Option<usize> { 566 bytes 567 .windows(4) 568 .position(|window| window == b"\r\n\r\n") 569 .map(|index| index + 4) 570 } 571 572 fn http_content_length(headers: &str) -> usize { 573 headers 574 .lines() 575 .find_map(|line| { 576 let (name, value) = line.split_once(':')?; 577 if name.eq_ignore_ascii_case("content-length") { 578 Some(value.trim().parse::<usize>().expect("content length")) 579 } else { 580 None 581 } 582 }) 583 .expect("content-length header") 584 } 585 586 fn handle_relay_fetch_connection(stream: TcpStream, events: Vec<RadrootsNostrEvent>) { 587 let mut websocket = tungstenite::accept(stream).expect("accept fetch websocket"); 588 let subscription_id = read_relay_req_subscription_id(&mut websocket); 589 for event in events { 590 websocket 591 .send(tungstenite::Message::Text( 592 json!(["EVENT", subscription_id, event]).to_string().into(), 593 )) 594 .expect("relay event send"); 595 } 596 websocket 597 .send(tungstenite::Message::Text( 598 json!(["EOSE", subscription_id]).to_string().into(), 599 )) 600 .expect("relay eose send"); 601 } 602 603 fn read_relay_req_subscription_id(websocket: &mut tungstenite::WebSocket<TcpStream>) -> String { 604 loop { 605 let message = websocket.read().expect("relay req message"); 606 if !message.is_text() { 607 continue; 608 } 609 let value: Value = 610 serde_json::from_str(message.to_text().expect("relay req text")).expect("relay json"); 611 if value.get(0).and_then(Value::as_str) == Some("REQ") { 612 return value 613 .get(1) 614 .and_then(Value::as_str) 615 .expect("subscription id") 616 .to_owned(); 617 } 618 } 619 } 620 621 fn seed_legacy_replica_sync_farm(sandbox: &RadrootsCliSandbox, d_tag: &str, pubkey: &str) { 622 let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica"); 623 migrations::run_all_up(&executor).expect("replica migrations"); 624 farm::create( 625 &executor, 626 &IFarmFields { 627 d_tag: d_tag.to_owned(), 628 pubkey: pubkey.to_owned(), 629 name: "Sync Push Farm".to_owned(), 630 about: Some("sync push process fixture".to_owned()), 631 website: None, 632 picture: None, 633 banner: None, 634 location_primary: None, 635 location_city: None, 636 location_region: None, 637 location_country: None, 638 }, 639 ) 640 .expect("seed legacy replica sync farm"); 641 } 642 643 fn seed_app_farm_record( 644 sandbox: &RadrootsCliSandbox, 645 account_id: &str, 646 seller_pubkey: &str, 647 farm_d_tag: &str, 648 ) { 649 append_app_local_record( 650 LocalEventRecordInput { 651 record_id: format!("app:local_work:farm:{farm_d_tag}:test"), 652 family: LocalRecordFamily::LocalWork, 653 status: LocalRecordStatus::LocalSaved, 654 source_runtime: SourceRuntime::App, 655 created_at_ms: 1_779_000_001_000, 656 inserted_at_ms: 1_779_000_001_000, 657 owner_account_id: Some(account_id.to_owned()), 658 owner_pubkey: Some(seller_pubkey.to_owned()), 659 farm_id: Some(farm_d_tag.to_owned()), 660 listing_addr: None, 661 local_work_json: Some(json!({ 662 "record_kind": "farm_config_v1", 663 "scope": "app", 664 "document": { 665 "version": 1, 666 "selection": { 667 "scope": "app", 668 "account": account_id, 669 "farm_d_tag": farm_d_tag, 670 }, 671 "profile": { 672 "name": "App Farm", 673 "display_name": "App Farm", 674 }, 675 "farm": { 676 "d_tag": farm_d_tag, 677 "name": "App Farm", 678 "location": { 679 "primary": "farmstand", 680 }, 681 }, 682 "listing_defaults": { 683 "delivery_method": "pickup", 684 "location": { 685 "primary": "farmstand", 686 }, 687 }, 688 }, 689 })), 690 event_id: None, 691 event_kind: None, 692 event_pubkey: None, 693 event_created_at: None, 694 event_tags_json: None, 695 event_content: None, 696 event_sig: None, 697 raw_event_json: None, 698 outbox_status: PublishOutboxStatus::None, 699 relay_set_fingerprint: None, 700 relay_delivery_json: None, 701 }, 702 sandbox, 703 ); 704 } 705 706 fn seed_app_listing_record( 707 sandbox: &RadrootsCliSandbox, 708 account_id: &str, 709 seller_pubkey: &str, 710 farm_d_tag: &str, 711 listing_d_tag: &str, 712 ) -> String { 713 seed_app_listing_record_variant( 714 sandbox, 715 account_id, 716 Some(seller_pubkey), 717 farm_d_tag, 718 listing_d_tag, 719 "test", 720 "App Eggs", 721 None, 722 ) 723 } 724 725 fn seed_app_listing_record_variant( 726 sandbox: &RadrootsCliSandbox, 727 account_id: &str, 728 seller_pubkey: Option<&str>, 729 farm_d_tag: &str, 730 listing_d_tag: &str, 731 record_suffix: &str, 732 title: &str, 733 exportability: Option<serde_json::Value>, 734 ) -> String { 735 seed_app_listing_record_variant_with_listing_addr( 736 sandbox, 737 account_id, 738 seller_pubkey, 739 farm_d_tag, 740 listing_d_tag, 741 record_suffix, 742 title, 743 exportability, 744 true, 745 ) 746 } 747 748 fn seed_app_listing_record_variant_without_listing_addr( 749 sandbox: &RadrootsCliSandbox, 750 account_id: &str, 751 seller_pubkey: Option<&str>, 752 farm_d_tag: &str, 753 listing_d_tag: &str, 754 record_suffix: &str, 755 title: &str, 756 ) -> String { 757 seed_app_listing_record_variant_with_listing_addr( 758 sandbox, 759 account_id, 760 seller_pubkey, 761 farm_d_tag, 762 listing_d_tag, 763 record_suffix, 764 title, 765 None, 766 false, 767 ) 768 } 769 770 fn seed_app_listing_record_variant_with_listing_addr( 771 sandbox: &RadrootsCliSandbox, 772 account_id: &str, 773 seller_pubkey: Option<&str>, 774 farm_d_tag: &str, 775 listing_d_tag: &str, 776 record_suffix: &str, 777 title: &str, 778 exportability: Option<serde_json::Value>, 779 include_listing_addr: bool, 780 ) -> String { 781 seed_app_listing_record_identity_variant( 782 sandbox, 783 account_id, 784 seller_pubkey, 785 seller_pubkey, 786 farm_d_tag, 787 listing_d_tag, 788 record_suffix, 789 title, 790 exportability, 791 include_listing_addr, 792 ) 793 } 794 795 fn seed_app_listing_record_identity_variant( 796 sandbox: &RadrootsCliSandbox, 797 account_id: &str, 798 document_seller_pubkey: Option<&str>, 799 owner_pubkey: Option<&str>, 800 farm_d_tag: &str, 801 listing_d_tag: &str, 802 record_suffix: &str, 803 title: &str, 804 exportability: Option<serde_json::Value>, 805 include_listing_addr: bool, 806 ) -> String { 807 let record_id = format!("app:local_work:listing:{listing_d_tag}:{record_suffix}"); 808 let seller_pubkey_json = document_seller_pubkey 809 .map(|value| json!(value)) 810 .unwrap_or_else(|| json!(null)); 811 let mut payload = json!({ 812 "record_kind": "listing_draft_v1", 813 "document": { 814 "version": 1, 815 "kind": "listing_draft_v1", 816 "listing": { 817 "d_tag": listing_d_tag, 818 "farm_d_tag": farm_d_tag, 819 }, 820 "seller_actor": { 821 "account_id": account_id, 822 "pubkey": seller_pubkey_json, 823 "source": "farm_config", 824 }, 825 "product": { 826 "key": listing_d_tag, 827 "title": title, 828 "category": "eggs", 829 "summary": "Fresh app eggs", 830 }, 831 "primary_bin": { 832 "bin_id": "bin-1", 833 "quantity_amount": "1", 834 "quantity_unit": "dozen", 835 "price_amount": "7.50", 836 "price_currency": "USD", 837 "price_per_amount": "1", 838 "price_per_unit": "dozen", 839 }, 840 "inventory": { 841 "available": "12", 842 }, 843 "availability": { 844 "kind": "local", 845 "status": "draft", 846 }, 847 "delivery": { 848 "method": "pickup", 849 }, 850 "location": { 851 "primary": "farmstand", 852 }, 853 }, 854 }); 855 if let Some(exportability) = exportability { 856 payload["exportability"] = exportability; 857 } 858 append_app_local_record( 859 LocalEventRecordInput { 860 record_id: record_id.clone(), 861 family: LocalRecordFamily::LocalWork, 862 status: LocalRecordStatus::LocalSaved, 863 source_runtime: SourceRuntime::App, 864 created_at_ms: 1_779_000_002_000, 865 inserted_at_ms: 1_779_000_002_000, 866 owner_account_id: Some(account_id.to_owned()), 867 owner_pubkey: owner_pubkey.map(str::to_owned), 868 farm_id: Some(farm_d_tag.to_owned()), 869 listing_addr: include_listing_addr 870 .then_some(owner_pubkey) 871 .flatten() 872 .map(|owner_pubkey| format!("30402:{owner_pubkey}:{listing_d_tag}")), 873 local_work_json: Some(payload), 874 event_id: None, 875 event_kind: None, 876 event_pubkey: None, 877 event_created_at: None, 878 event_tags_json: None, 879 event_content: None, 880 event_sig: None, 881 raw_event_json: None, 882 outbox_status: PublishOutboxStatus::None, 883 relay_set_fingerprint: None, 884 relay_delivery_json: None, 885 }, 886 sandbox, 887 ); 888 record_id 889 } 890 891 fn append_app_local_record(input: LocalEventRecordInput, sandbox: &RadrootsCliSandbox) { 892 let database_path = sandbox.local_events_db_path(); 893 fs::create_dir_all(database_path.parent().expect("local events parent")) 894 .expect("local events parent"); 895 let executor = SqliteExecutor::open(database_path).expect("open local events"); 896 let store = LocalEventsStore::new(executor); 897 store.migrate_up().expect("migrate local events"); 898 store 899 .append_record(&input) 900 .expect("append app local event record"); 901 } 902 903 fn seed_app_order_record( 904 sandbox: &RadrootsCliSandbox, 905 account_id: &str, 906 buyer_pubkey: &str, 907 seller_pubkey: &str, 908 order_id: &str, 909 listing_addr: &str, 910 listing_event_id: &str, 911 ) -> String { 912 seed_app_order_record_variant( 913 sandbox, 914 account_id, 915 buyer_pubkey, 916 seller_pubkey, 917 order_id, 918 listing_addr, 919 listing_event_id, 920 true, 921 "supported", 922 Vec::new(), 923 ) 924 } 925 926 fn seed_app_order_record_variant( 927 sandbox: &RadrootsCliSandbox, 928 account_id: &str, 929 buyer_pubkey: &str, 930 seller_pubkey: &str, 931 order_id: &str, 932 listing_addr: &str, 933 listing_event_id: &str, 934 current: bool, 935 support_state: &str, 936 support_issues: Vec<&str>, 937 ) -> String { 938 let record_id = format!("app:local_work:order_request:{order_id}"); 939 seed_app_order_record_variant_with_record_id( 940 sandbox, 941 account_id, 942 buyer_pubkey, 943 seller_pubkey, 944 order_id, 945 listing_addr, 946 listing_event_id, 947 record_id, 948 current, 949 support_state, 950 support_issues, 951 ) 952 } 953 954 fn seed_app_order_record_variant_with_record_id( 955 sandbox: &RadrootsCliSandbox, 956 account_id: &str, 957 buyer_pubkey: &str, 958 seller_pubkey: &str, 959 order_id: &str, 960 listing_addr: &str, 961 listing_event_id: &str, 962 record_id: String, 963 current: bool, 964 support_state: &str, 965 support_issues: Vec<&str>, 966 ) -> String { 967 let support_issues = support_issues 968 .into_iter() 969 .map(|issue| Value::String(issue.to_owned())) 970 .collect::<Vec<_>>(); 971 let payload = json!({ 972 "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, 973 "scope": "app", 974 "exportability": { 975 "state": "exportable", 976 }, 977 "support_status": { 978 "state": support_state, 979 "issues": support_issues, 980 }, 981 "currentness": { 982 "current": current, 983 "source": "app_sqlite_order", 984 "record_id": record_id, 985 "order_id": order_id, 986 "order_updated_at": "2026-05-24T12:00:10Z", 987 "created_at_ms": 1_779_000_010_000_i64, 988 }, 989 "document": { 990 "version": 1, 991 "kind": "order_draft_v1", 992 "order": { 993 "order_id": order_id, 994 "listing_addr": listing_addr, 995 "listing_event_id": listing_event_id, 996 "listing_relays": [ORDERABLE_LISTING_RELAY], 997 "buyer_pubkey": buyer_pubkey, 998 "seller_pubkey": seller_pubkey, 999 "items": [ 1000 { 1001 "bin_id": "bin-1", 1002 "bin_count": 2, 1003 } 1004 ], 1005 "economics": { 1006 "quote_id": format!("app-order:{order_id}"), 1007 "quote_version": 1, 1008 "pricing_basis": "listing_event", 1009 "currency": "USD", 1010 "items": [ 1011 { 1012 "bin_id": "bin-1", 1013 "bin_count": 2, 1014 "quantity_amount": "1", 1015 "quantity_unit": "each", 1016 "unit_price_amount": "6", 1017 "unit_price_currency": "USD", 1018 "line_subtotal": { 1019 "amount": "12", 1020 "currency": "USD", 1021 }, 1022 } 1023 ], 1024 "discounts": [], 1025 "adjustments": [], 1026 "subtotal": { 1027 "amount": "12", 1028 "currency": "USD", 1029 }, 1030 "discount_total": { 1031 "amount": "0", 1032 "currency": "USD", 1033 }, 1034 "adjustment_total": { 1035 "amount": "0", 1036 "currency": "USD", 1037 }, 1038 "total": { 1039 "amount": "12", 1040 "currency": "USD", 1041 }, 1042 }, 1043 }, 1044 "buyer_actor": { 1045 "account_id": account_id, 1046 "pubkey": buyer_pubkey, 1047 "source": "resolved_account", 1048 }, 1049 "listing_lookup": listing_addr, 1050 }, 1051 "app_order": { 1052 "order_id": order_id, 1053 "order_number": 1, 1054 "farm_id": "018f47a8-7b2c-7000-8000-0000000000f1", 1055 "farm_display_name": "CLI Interop Farm", 1056 "farm_key": "pasture-eggs", 1057 "status": "placed", 1058 "buyer_context_key": "buyer_context", 1059 "lines": [ 1060 { 1061 "line_id": format!("{order_id}:product-eggs"), 1062 "product_id": "product-eggs", 1063 "listing_addr": listing_addr, 1064 "listing_event_id": listing_event_id, 1065 "seller_pubkey": seller_pubkey, 1066 } 1067 ], 1068 }, 1069 }); 1070 append_app_local_record( 1071 LocalEventRecordInput { 1072 record_id: record_id.clone(), 1073 family: LocalRecordFamily::LocalWork, 1074 status: LocalRecordStatus::LocalSaved, 1075 source_runtime: SourceRuntime::App, 1076 created_at_ms: 1_779_000_010_000, 1077 inserted_at_ms: 1_779_000_010_000, 1078 owner_account_id: Some(account_id.to_owned()), 1079 owner_pubkey: Some(buyer_pubkey.to_owned()), 1080 farm_id: Some("018f47a8-7b2c-7000-8000-0000000000f1".to_owned()), 1081 listing_addr: Some(listing_addr.to_owned()), 1082 local_work_json: Some(payload), 1083 event_id: None, 1084 event_kind: None, 1085 event_pubkey: None, 1086 event_created_at: None, 1087 event_tags_json: None, 1088 event_content: None, 1089 event_sig: None, 1090 raw_event_json: None, 1091 outbox_status: PublishOutboxStatus::None, 1092 relay_set_fingerprint: None, 1093 relay_delivery_json: None, 1094 }, 1095 sandbox, 1096 ); 1097 record_id 1098 } 1099 1100 fn app_order_economics(order_id: &str, bin_count: u32) -> RadrootsOrderEconomics { 1101 let line_total = (bin_count * 6).to_string(); 1102 serde_json::from_value(json!({ 1103 "quote_id": format!("app-order:{order_id}"), 1104 "quote_version": 1, 1105 "pricing_basis": "listing_event", 1106 "currency": "USD", 1107 "items": [ 1108 { 1109 "bin_id": "bin-1", 1110 "bin_count": bin_count, 1111 "quantity_amount": "1", 1112 "quantity_unit": "each", 1113 "unit_price_amount": "6", 1114 "unit_price_currency": "USD", 1115 "line_subtotal": { 1116 "amount": line_total, 1117 "currency": "USD", 1118 }, 1119 } 1120 ], 1121 "discounts": [], 1122 "adjustments": [], 1123 "subtotal": { 1124 "amount": line_total, 1125 "currency": "USD", 1126 }, 1127 "discount_total": { 1128 "amount": "0", 1129 "currency": "USD", 1130 }, 1131 "adjustment_total": { 1132 "amount": "0", 1133 "currency": "USD", 1134 }, 1135 "total": { 1136 "amount": line_total, 1137 "currency": "USD", 1138 }, 1139 })) 1140 .expect("app order economics") 1141 } 1142 1143 fn signed_app_order_request_event( 1144 buyer: &radroots_identity::RadrootsIdentity, 1145 order_id: &str, 1146 listing_addr: &str, 1147 listing_event_id: &str, 1148 seller_pubkey: &str, 1149 bin_count: u32, 1150 ) -> RadrootsNostrEvent { 1151 let payload = RadrootsOrderRequest { 1152 order_id: test_order_id(order_id), 1153 listing_addr: test_listing_addr(listing_addr), 1154 buyer_pubkey: test_pubkey(buyer.public_key_hex().as_str()), 1155 seller_pubkey: test_pubkey(seller_pubkey), 1156 items: vec![RadrootsOrderItem { 1157 bin_id: test_inventory_bin_id("bin-1"), 1158 bin_count, 1159 }], 1160 economics: app_order_economics(order_id, bin_count), 1161 }; 1162 let parts = order_request_event_build( 1163 &RadrootsNostrEventPtr { 1164 id: listing_event_id.to_owned(), 1165 relays: None, 1166 }, 1167 &payload, 1168 ) 1169 .expect("app order request parts"); 1170 radroots_nostr_build_event(parts.kind, parts.content, parts.tags) 1171 .expect("nostr event builder") 1172 .sign_with_keys(buyer.keys()) 1173 .expect("signed app order request") 1174 } 1175 1176 fn append_app_signed_order_request_record( 1177 sandbox: &RadrootsCliSandbox, 1178 account_id: &str, 1179 listing_addr: &str, 1180 event: &RadrootsNostrEvent, 1181 ) -> String { 1182 let event_id = event.id.to_hex(); 1183 let event_tags = event 1184 .tags 1185 .iter() 1186 .map(|tag| tag.as_slice().to_vec()) 1187 .collect::<Vec<_>>(); 1188 let delivery = RelayDeliveryEvidence::acknowledged( 1189 [ORDERABLE_LISTING_RELAY], 1190 [ORDERABLE_LISTING_RELAY], 1191 [ORDERABLE_LISTING_RELAY], 1192 Vec::new(), 1193 ) 1194 .expect("order request delivery evidence"); 1195 let record_id = format!("app:signed_event:{event_id}"); 1196 append_app_local_record( 1197 LocalEventRecordInput { 1198 record_id: record_id.clone(), 1199 family: LocalRecordFamily::SignedEvent, 1200 status: LocalRecordStatus::Published, 1201 source_runtime: SourceRuntime::App, 1202 created_at_ms: i64::try_from(event.created_at.as_secs()).expect("event created_at") 1203 * 1_000, 1204 inserted_at_ms: 1_779_000_011_000, 1205 owner_account_id: Some(account_id.to_owned()), 1206 owner_pubkey: Some(event.pubkey.to_string()), 1207 farm_id: Some("018f47a8-7b2c-7000-8000-0000000000f1".to_owned()), 1208 listing_addr: Some(listing_addr.to_owned()), 1209 local_work_json: None, 1210 event_id: Some(event_id), 1211 event_kind: Some(i64::from(KIND_ORDER_REQUEST)), 1212 event_pubkey: Some(event.pubkey.to_string()), 1213 event_created_at: Some( 1214 i64::try_from(event.created_at.as_secs()).expect("event created_at"), 1215 ), 1216 event_tags_json: Some(json!(event_tags.clone())), 1217 event_content: Some(event.content.clone()), 1218 event_sig: Some(event.sig.to_string()), 1219 raw_event_json: Some(json!({ 1220 "id": event.id.to_hex(), 1221 "pubkey": event.pubkey.to_string(), 1222 "created_at": i64::try_from(event.created_at.as_secs()).expect("event created_at"), 1223 "kind": u32::from(event.kind.as_u16()), 1224 "tags": event_tags, 1225 "content": event.content.clone(), 1226 "sig": event.sig.to_string(), 1227 })), 1228 outbox_status: PublishOutboxStatus::Acknowledged, 1229 relay_set_fingerprint: canonical_relay_set_fingerprint([ORDERABLE_LISTING_RELAY]), 1230 relay_delivery_json: Some(delivery.to_json_value().expect("delivery json")), 1231 }, 1232 sandbox, 1233 ); 1234 record_id 1235 } 1236 1237 #[test] 1238 fn root_help_exposes_only_target_namespaces() { 1239 let output = radroots().arg("--help").output().expect("run root help"); 1240 1241 assert!(output.status.success()); 1242 let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); 1243 for namespace in [ 1244 "workspace", 1245 "health", 1246 "config", 1247 "account", 1248 "signer", 1249 "relay", 1250 "store", 1251 "sync", 1252 "farm", 1253 "listing", 1254 "market", 1255 "basket", 1256 "order", 1257 ] { 1258 assert!( 1259 help_lists(&stdout, namespace), 1260 "root help should contain `{namespace}`" 1261 ); 1262 } 1263 1264 for removed in [ 1265 "setup", "status", "doctor", "sell", "find", "local", "net", "myc", "rpc", "product", 1266 "runtime", "job", "message", "approval", "agent", 1267 ] { 1268 assert!( 1269 !help_lists(&stdout, removed), 1270 "root help should not contain `{removed}`" 1271 ); 1272 } 1273 } 1274 1275 #[test] 1276 fn root_help_explains_publish_transports() { 1277 let output = radroots().arg("--help").output().expect("run root help"); 1278 1279 assert!(output.status.success()); 1280 let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); 1281 1282 assert!(stdout.contains("direct_nostr_relay publishes directly to configured relays")); 1283 assert!(stdout.contains("radrootsd_proxy publishes locally signed events")); 1284 assert!(stdout.contains("Inspect local readiness and mode-specific recovery steps")); 1285 assert!(stdout.contains( 1286 "Select direct_nostr_relay direct relay publish or radrootsd_proxy daemon proxy publish" 1287 )); 1288 } 1289 1290 fn help_lists(stdout: &str, command: &str) -> bool { 1291 stdout.lines().any(|line| { 1292 let line = line.trim_start(); 1293 line == command || line.starts_with(&format!("{command} ")) 1294 }) 1295 } 1296 1297 #[test] 1298 fn removed_global_flags_are_rejected_publicly() { 1299 for args in [ 1300 ["--output", "json", "workspace", "get"].as_slice(), 1301 ["--json", "workspace", "get"].as_slice(), 1302 ["--ndjson", "workspace", "get"].as_slice(), 1303 ["--yes", "workspace", "get"].as_slice(), 1304 ["--non-interactive", "workspace", "get"].as_slice(), 1305 ["--signer", "myc", "workspace", "get"].as_slice(), 1306 ["--farm-id", "farm_test", "workspace", "get"].as_slice(), 1307 ["--profile", "repo_local", "workspace", "get"].as_slice(), 1308 ["--signer-session-id", "session_test", "workspace", "get"].as_slice(), 1309 ] { 1310 let output = radroots().args(args).output().expect("run removed flag"); 1311 1312 assert!(!output.status.success(), "`{args:?}` should be rejected"); 1313 let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); 1314 assert!(stderr.contains("unexpected argument") || stderr.contains("unrecognized")); 1315 } 1316 } 1317 1318 #[test] 1319 fn config_get_exposes_radrootsd_proxy_missing_token_state() { 1320 let sandbox = RadrootsCliSandbox::new(); 1321 sandbox.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); 1322 1323 let value = sandbox.json_success(&["--format", "json", "config", "get"]); 1324 1325 assert_eq!(value["operation_id"], "config.get"); 1326 assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); 1327 assert_eq!( 1328 value["result"]["publish"]["source"], 1329 "user config ยท local first" 1330 ); 1331 assert_eq!( 1332 value["result"]["publish"]["transport_family"], 1333 "radrootsd_proxy" 1334 ); 1335 assert_eq!(value["result"]["publish"]["state"], "unconfigured"); 1336 assert_eq!(value["result"]["publish"]["executable"], false); 1337 assert_contains( 1338 &value["result"]["publish"]["reason"], 1339 "configured token file or token secret id", 1340 ); 1341 assert_eq!( 1342 value["result"]["account_resolution"]["status"], 1343 "unresolved" 1344 ); 1345 assert_eq!( 1346 value["result"]["publish"]["provider"]["provider_runtime_id"], 1347 "radrootsd_proxy" 1348 ); 1349 assert_eq!( 1350 value["result"]["write_plane"]["provider_runtime_id"], 1351 "radrootsd_proxy" 1352 ); 1353 assert_eq!( 1354 value["result"]["write_plane"]["binding_model"], 1355 "daemon_proxy_publish" 1356 ); 1357 assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); 1358 assert_eq!( 1359 value["result"]["radrootsd_proxy"]["token_file_configured"], 1360 false 1361 ); 1362 assert_eq!( 1363 value["result"]["radrootsd_proxy"]["token_secret_id_configured"], 1364 false 1365 ); 1366 assert_eq!( 1367 value["result"]["actions"][0], 1368 "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" 1369 ); 1370 assert_eq!( 1371 value["next_actions"][0]["env_var"], 1372 "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE" 1373 ); 1374 } 1375 1376 #[test] 1377 fn config_get_radrootsd_proxy_with_token_file_reports_ready_transport() { 1378 let sandbox = RadrootsCliSandbox::new(); 1379 sandbox.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); 1380 let token_file = radrootsd_proxy_token_file(&sandbox); 1381 1382 let mut command = sandbox.command(); 1383 command 1384 .env("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", token_file) 1385 .args(["--format", "json", "config", "get"]); 1386 let output = command.output().expect("run config get"); 1387 let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); 1388 1389 assert!(output.status.success()); 1390 assert_eq!(value["operation_id"], "config.get"); 1391 assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); 1392 assert_eq!(value["result"]["publish"]["state"], "ready"); 1393 assert_eq!(value["result"]["publish"]["executable"], true); 1394 assert_eq!(value["result"]["publish"]["reason"], Value::Null); 1395 assert_eq!( 1396 value["result"]["radrootsd_proxy"]["token_file_configured"], 1397 true 1398 ); 1399 assert_eq!( 1400 value["result"]["actions"] 1401 .as_array() 1402 .expect("actions") 1403 .len(), 1404 0 1405 ); 1406 } 1407 1408 #[test] 1409 fn config_get_marks_radrootsd_proxy_unconfigured_with_incomplete_myc_signer() { 1410 let sandbox = RadrootsCliSandbox::new(); 1411 sandbox.write_app_config( 1412 r#"[publish] 1413 transport = "radrootsd_proxy" 1414 1415 [signer] 1416 backend = "myc" 1417 1418 [[capability_binding]] 1419 capability = "signer.remote_nip46" 1420 provider = "myc" 1421 target_kind = "explicit_endpoint" 1422 target = "http://myc.invalid" 1423 signer_session_ref = "session_ready" 1424 "#, 1425 ); 1426 let token_file = radrootsd_proxy_token_file(&sandbox); 1427 1428 let mut command = sandbox.command(); 1429 command 1430 .env("RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", token_file) 1431 .args(["--format", "json", "config", "get"]); 1432 let output = command.output().expect("run config get"); 1433 let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); 1434 1435 assert!(output.status.success()); 1436 assert_eq!(value["operation_id"], "config.get"); 1437 assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); 1438 assert_eq!(value["result"]["publish"]["state"], "unconfigured"); 1439 assert_eq!(value["result"]["publish"]["executable"], false); 1440 assert_contains(&value["result"]["publish"]["reason"], "signer.remote_nip46"); 1441 assert_eq!( 1442 value["result"]["publish"]["provider"]["state"], 1443 "unconfigured" 1444 ); 1445 assert_eq!(value["result"]["actions"][0], "radroots signer status get"); 1446 } 1447 1448 #[test] 1449 fn config_get_distinguishes_relay_ready_from_missing_signed_write_account() { 1450 let sandbox = RadrootsCliSandbox::new(); 1451 1452 let value = sandbox.json_success(&[ 1453 "--format", 1454 "json", 1455 "--relay", 1456 "ws://127.0.0.1:19001", 1457 "config", 1458 "get", 1459 ]); 1460 1461 assert_eq!(value["operation_id"], "config.get"); 1462 assert_eq!( 1463 value["result"]["publish"]["transport"], 1464 "direct_nostr_relay" 1465 ); 1466 assert_eq!(value["result"]["publish"]["relay"]["ready"], true); 1467 assert_eq!(value["result"]["publish"]["signed_write_required"], true); 1468 assert_eq!(value["result"]["publish"]["state"], "unconfigured"); 1469 assert_eq!(value["result"]["publish"]["executable"], false); 1470 assert_contains( 1471 &value["result"]["publish"]["reason"], 1472 "write-capable local account", 1473 ); 1474 assert_eq!( 1475 value["result"]["publish"]["provider"]["state"], 1476 "unconfigured" 1477 ); 1478 assert_eq!( 1479 value["result"]["write_plane"]["provider_runtime_id"], 1480 "direct_nostr_relay" 1481 ); 1482 assert_eq!( 1483 value["result"]["write_plane"]["binding_model"], 1484 "direct_relay_publish" 1485 ); 1486 assert_eq!(value["result"]["write_plane"]["state"], "unconfigured"); 1487 assert_eq!(value["result"]["rpc"], Value::Null); 1488 assert_contains( 1489 &value["result"]["write_plane"]["detail"], 1490 "write-capable local account", 1491 ); 1492 assert_eq!(value["result"]["actions"][0], "radroots account create"); 1493 assert_eq!( 1494 value["next_actions"][0]["command"], 1495 "radroots account create" 1496 ); 1497 assert_no_daemon_runtime_reference( 1498 &value, 1499 &[ 1500 "--format", 1501 "json", 1502 "--relay", 1503 "ws://127.0.0.1:19001", 1504 "config", 1505 "get", 1506 ], 1507 ); 1508 } 1509 1510 #[test] 1511 fn config_get_marks_relay_publish_ready_with_secret_backed_local_account() { 1512 let sandbox = RadrootsCliSandbox::new(); 1513 sandbox.json_success(&["--format", "json", "account", "create"]); 1514 1515 let value = sandbox.json_success(&[ 1516 "--format", 1517 "json", 1518 "--relay", 1519 "ws://127.0.0.1:19002", 1520 "config", 1521 "get", 1522 ]); 1523 1524 assert_eq!( 1525 value["result"]["publish"]["transport"], 1526 "direct_nostr_relay" 1527 ); 1528 assert_eq!(value["result"]["publish"]["relay"]["ready"], true); 1529 assert_eq!(value["result"]["publish"]["signed_write_required"], true); 1530 assert_eq!(value["result"]["publish"]["state"], "ready"); 1531 assert_eq!(value["result"]["publish"]["executable"], true); 1532 assert_eq!(value["result"]["publish"]["reason"], Value::Null); 1533 assert_eq!(value["result"]["publish"]["provider"]["state"], "ready"); 1534 } 1535 1536 #[test] 1537 fn config_get_marks_relay_publish_unconfigured_with_missing_myc_binding() { 1538 let sandbox = RadrootsCliSandbox::new(); 1539 sandbox.json_success(&["--format", "json", "account", "create"]); 1540 sandbox.write_app_config("[signer]\nbackend = \"myc\"\n"); 1541 1542 let value = sandbox.json_success(&[ 1543 "--format", 1544 "json", 1545 "--relay", 1546 "ws://127.0.0.1:19003", 1547 "config", 1548 "get", 1549 ]); 1550 1551 assert_eq!( 1552 value["result"]["publish"]["transport"], 1553 "direct_nostr_relay" 1554 ); 1555 assert_eq!(value["result"]["publish"]["relay"]["ready"], true); 1556 assert_eq!(value["result"]["publish"]["signed_write_required"], true); 1557 assert_eq!(value["result"]["publish"]["state"], "unconfigured"); 1558 assert_eq!(value["result"]["publish"]["executable"], false); 1559 assert_contains( 1560 &value["result"]["publish"]["reason"], 1561 "signer.remote_nip46 binding is missing", 1562 ); 1563 assert_eq!( 1564 value["result"]["publish"]["provider"]["state"], 1565 "unconfigured" 1566 ); 1567 } 1568 1569 #[test] 1570 fn config_get_marks_relay_publish_unconfigured_with_watch_only_account() { 1571 let sandbox = RadrootsCliSandbox::new(); 1572 let public_identity = identity_public(41); 1573 let public_identity_file = 1574 write_public_identity_profile(&sandbox, "publish-readiness-watch-only", &public_identity); 1575 sandbox.json_success(&[ 1576 "--format", 1577 "json", 1578 "--approval-token", 1579 "approve", 1580 "account", 1581 "import", 1582 "--default", 1583 public_identity_file.to_string_lossy().as_ref(), 1584 ]); 1585 1586 let value = sandbox.json_success(&[ 1587 "--format", 1588 "json", 1589 "--relay", 1590 "ws://127.0.0.1:19004", 1591 "config", 1592 "get", 1593 ]); 1594 1595 assert_eq!(value["result"]["publish"]["relay"]["ready"], true); 1596 assert_eq!(value["result"]["publish"]["signed_write_required"], true); 1597 assert_eq!(value["result"]["publish"]["state"], "unconfigured"); 1598 assert_eq!(value["result"]["publish"]["executable"], false); 1599 assert_contains(&value["result"]["publish"]["reason"], "watch_only"); 1600 } 1601 1602 #[test] 1603 fn health_surfaces_publish_state_under_missing_myc_binding() { 1604 let sandbox = RadrootsCliSandbox::new(); 1605 let missing_myc = sandbox.root().join("bin/missing-myc"); 1606 let token_file = radrootsd_proxy_token_file(&sandbox); 1607 sandbox.write_app_config(&format!( 1608 "[publish]\ntransport = \"radrootsd_proxy\"\n\n[publish.radrootsd_proxy]\ntoken_file = \"{}\"\n\n[signer]\nbackend = \"myc\"\n\n[myc]\nexecutable = \"{}\"\n", 1609 toml_string(token_file.display().to_string().as_str()), 1610 toml_string(missing_myc.display().to_string().as_str()) 1611 )); 1612 1613 let value = sandbox.json_success(&["--format", "json", "health", "status", "get"]); 1614 1615 assert_eq!(value["operation_id"], "health.status.get"); 1616 assert_eq!(value["result"]["state"], "needs_attention"); 1617 assert_eq!(value["result"]["publish"]["transport"], "radrootsd_proxy"); 1618 assert_eq!(value["result"]["publish"]["executable"], false); 1619 assert_eq!( 1620 value["result"]["publish"]["provider"]["state"], 1621 "unconfigured" 1622 ); 1623 assert_contains( 1624 &value["result"]["publish"]["reason"], 1625 "signer.remote_nip46 binding is missing", 1626 ); 1627 assert_eq!(value["result"]["store"]["state"], "ready"); 1628 assert_eq!( 1629 value["result"]["store"]["source"], 1630 "SDK canonical event store and outbox" 1631 ); 1632 assert_eq!(value["result"]["store"]["canonical_store"], "sdk"); 1633 assert_eq!(value["result"]["signer"]["state"], "unconfigured"); 1634 assert_eq!(value["result"]["actions"][0], "radroots account create"); 1635 assert_eq!(value["result"]["actions"][1], "radroots signer status get"); 1636 assert_eq!( 1637 value["next_actions"][0]["command"], 1638 "radroots account create" 1639 ); 1640 assert_eq!( 1641 value["next_actions"][1]["command"], 1642 "radroots signer status get" 1643 ); 1644 assert_eq!(value["errors"].as_array().expect("errors").len(), 0); 1645 } 1646 1647 #[test] 1648 fn health_status_distinguishes_relay_ready_from_missing_signed_write_account() { 1649 let sandbox = RadrootsCliSandbox::new(); 1650 1651 let value = sandbox.json_success(&[ 1652 "--format", 1653 "json", 1654 "--relay", 1655 "ws://127.0.0.1:19005", 1656 "health", 1657 "status", 1658 "get", 1659 ]); 1660 1661 assert_eq!(value["operation_id"], "health.status.get"); 1662 assert_eq!(value["result"]["state"], "needs_attention"); 1663 assert_eq!(value["result"]["publish"]["relay"]["ready"], true); 1664 assert_eq!(value["result"]["publish"]["signed_write_required"], true); 1665 assert_eq!(value["result"]["publish"]["state"], "unconfigured"); 1666 assert_eq!(value["result"]["publish"]["executable"], false); 1667 assert_contains( 1668 &value["result"]["publish"]["reason"], 1669 "write-capable local account", 1670 ); 1671 assert_eq!(value["result"]["store"]["state"], "ready"); 1672 assert_eq!(value["result"]["store"]["canonical_store"], "sdk"); 1673 assert_eq!(value["result"]["signer"]["state"], "unconfigured"); 1674 assert_eq!(value["result"]["actions"][0], "radroots account create"); 1675 assert_eq!( 1676 value["next_actions"][0]["command"], 1677 "radroots account create" 1678 ); 1679 } 1680 1681 #[test] 1682 fn health_check_exposes_publish_readiness() { 1683 let sandbox = RadrootsCliSandbox::new(); 1684 sandbox.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); 1685 1686 let value = sandbox.json_success(&["--format", "json", "health", "check", "run"]); 1687 1688 assert_eq!(value["operation_id"], "health.check.run"); 1689 assert_eq!(value["result"]["state"], "needs_attention"); 1690 assert_eq!( 1691 value["result"]["account_resolution"]["status"], 1692 "unresolved" 1693 ); 1694 assert_eq!(value["result"]["account_resolution"]["source"], "none"); 1695 assert_eq!( 1696 value["result"]["checks"]["publish"]["transport"], 1697 "radrootsd_proxy" 1698 ); 1699 assert_eq!( 1700 value["result"]["checks"]["publish"]["state"], 1701 "unconfigured" 1702 ); 1703 assert_eq!(value["result"]["checks"]["publish"]["executable"], false); 1704 assert_contains( 1705 &value["result"]["checks"]["publish"]["reason"], 1706 "configured token file or token secret id", 1707 ); 1708 assert_eq!(value["result"]["checks"]["store"]["state"], "ready"); 1709 assert_eq!( 1710 value["result"]["checks"]["store"]["source"], 1711 "SDK canonical event store and outbox" 1712 ); 1713 assert_eq!(value["result"]["checks"]["store"]["canonical_store"], "sdk"); 1714 assert_eq!(value["result"]["checks"]["signer"]["state"], "unconfigured"); 1715 assert_eq!(value["result"]["actions"][0], "radroots account create"); 1716 assert_eq!( 1717 value["result"]["actions"][1], 1718 "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" 1719 ); 1720 assert_eq!( 1721 value["next_actions"][0]["command"], 1722 "radroots account create" 1723 ); 1724 assert_eq!( 1725 value["next_actions"][1]["description"], 1726 "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID" 1727 ); 1728 assert_eq!( 1729 value["next_actions"][1]["env_var"], 1730 "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE" 1731 ); 1732 assert_eq!(value["errors"].as_array().expect("errors").len(), 0); 1733 } 1734 1735 #[test] 1736 fn health_check_marks_relay_publish_ready_with_secret_backed_local_account() { 1737 let sandbox = RadrootsCliSandbox::new(); 1738 sandbox.json_success(&["--format", "json", "workspace", "init"]); 1739 sandbox.json_success(&["--format", "json", "account", "create"]); 1740 1741 let value = sandbox.json_success(&[ 1742 "--format", 1743 "json", 1744 "--relay", 1745 "ws://127.0.0.1:19006", 1746 "health", 1747 "check", 1748 "run", 1749 ]); 1750 1751 assert_eq!(value["operation_id"], "health.check.run"); 1752 assert_eq!(value["result"]["state"], "ready"); 1753 assert_eq!(value["result"]["account_resolution"]["status"], "resolved"); 1754 assert_eq!( 1755 value["result"]["account_resolution"]["source"], 1756 "default_account" 1757 ); 1758 assert_eq!( 1759 value["result"]["account_resolution"]["resolved_account"]["custody"], 1760 "secret_backed" 1761 ); 1762 assert_eq!( 1763 value["result"]["account_resolution"]["resolved_account"]["write_capable"], 1764 true 1765 ); 1766 assert_eq!( 1767 value["result"]["checks"]["publish"]["transport"], 1768 "direct_nostr_relay" 1769 ); 1770 assert_eq!(value["result"]["checks"]["publish"]["state"], "ready"); 1771 assert_eq!(value["result"]["checks"]["publish"]["executable"], true); 1772 assert_eq!( 1773 value["result"]["actions"] 1774 .as_array() 1775 .expect("actions") 1776 .len(), 1777 0 1778 ); 1779 assert_eq!(value["errors"].as_array().expect("errors").len(), 0); 1780 } 1781 1782 #[test] 1783 fn farm_readiness_check_reports_mode_specific_publish_gates() { 1784 let sandbox = RadrootsCliSandbox::new(); 1785 sandbox.json_success(&["--format", "json", "account", "create"]); 1786 sandbox.json_success(&[ 1787 "--format", 1788 "json", 1789 "farm", 1790 "create", 1791 "--name", 1792 "Ready Farm", 1793 "--location", 1794 "farmstand", 1795 "--country", 1796 "US", 1797 "--delivery-method", 1798 "pickup", 1799 ]); 1800 1801 let (_, relay_value) = sandbox.json_output(&["--format", "json", "farm", "readiness", "check"]); 1802 let relay_detail = if relay_value["result"].is_null() { 1803 &relay_value["errors"][0]["detail"] 1804 } else { 1805 &relay_value["result"] 1806 }; 1807 assert_eq!(relay_detail["publish_transport"], "direct_nostr_relay"); 1808 assert_eq!(relay_detail["publish_state"], "unconfigured"); 1809 assert_eq!(relay_detail["publish_executable"], false); 1810 assert_eq!(relay_detail["missing"][0], "Configured relay"); 1811 1812 sandbox.write_app_config( 1813 r#"[[capability_binding]] 1814 capability = "signer.remote_nip46" 1815 provider = "myc" 1816 target_kind = "explicit_endpoint" 1817 target = "http://myc.invalid" 1818 signer_session_ref = "session_test" 1819 "#, 1820 ); 1821 let output = sandbox 1822 .command() 1823 .env( 1824 "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", 1825 radrootsd_proxy_token_file(&sandbox), 1826 ) 1827 .args([ 1828 "--format", 1829 "json", 1830 "--publish-transport", 1831 "radrootsd_proxy", 1832 "farm", 1833 "readiness", 1834 "check", 1835 ]) 1836 .output() 1837 .expect("run radrootsd proxy farm readiness"); 1838 let radrootsd_value: Value = serde_json::from_slice(&output.stdout).expect("json output"); 1839 1840 assert!(output.status.success()); 1841 assert_eq!(radrootsd_value["operation_id"], "farm.readiness.check"); 1842 assert_contains( 1843 &radrootsd_value["result"]["publish_transport"], 1844 "radrootsd_proxy", 1845 ); 1846 assert_eq!(radrootsd_value["result"]["publish_state"], "ready"); 1847 assert_eq!(radrootsd_value["result"]["publish_executable"], true); 1848 assert_eq!(radrootsd_value["result"]["reason"], Value::Null); 1849 assert_eq!( 1850 radrootsd_value["result"]["actions"][0], 1851 "radroots farm publish" 1852 ); 1853 } 1854 1855 #[test] 1856 fn radrootsd_proxy_listing_publish_update_and_archive_dry_run_without_direct_relays() { 1857 for operation in ["publish", "update", "archive"] { 1858 let sandbox = RadrootsCliSandbox::new(); 1859 sandbox.json_success(&["--format", "json", "account", "create"]); 1860 let farm = sandbox.json_success(&[ 1861 "--format", 1862 "json", 1863 "farm", 1864 "create", 1865 "--name", 1866 "Proxy Farm", 1867 "--location", 1868 "farmstand", 1869 "--country", 1870 "US", 1871 "--delivery-method", 1872 "pickup", 1873 ]); 1874 let listing_file = 1875 create_listing_draft(&sandbox, format!("radrootsd-proxy-{operation}").as_str()); 1876 make_listing_publishable( 1877 &listing_file, 1878 farm["result"]["config"]["farm_d_tag"] 1879 .as_str() 1880 .expect("farm d tag"), 1881 ); 1882 1883 let output = sandbox 1884 .command() 1885 .env( 1886 "RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE", 1887 radrootsd_proxy_token_file(&sandbox), 1888 ) 1889 .args([ 1890 "--format", 1891 "json", 1892 "--publish-transport", 1893 "radrootsd_proxy", 1894 "--dry-run", 1895 "listing", 1896 operation, 1897 listing_file.to_string_lossy().as_ref(), 1898 ]) 1899 .output() 1900 .expect("run proxy listing dry run"); 1901 let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); 1902 1903 assert!(output.status.success()); 1904 assert_eq!(value["operation_id"], format!("listing.{operation}")); 1905 assert_eq!(value["result"]["state"], "dry_run"); 1906 assert_eq!( 1907 value["result"]["source"], 1908 "SDK listing publish ยท configured signer" 1909 ); 1910 assert_eq!(value["result"]["dry_run"], true); 1911 assert_eq!( 1912 value["result"]["target_relays"] 1913 .as_array() 1914 .expect("relays") 1915 .len(), 1916 0 1917 ); 1918 assert_contains( 1919 &value["result"]["reason"], 1920 "SDK enqueue and relay push skipped", 1921 ); 1922 } 1923 } 1924 1925 #[test] 1926 fn radrootsd_proxy_listing_publish_non_dry_run_uses_local_jsonrpc_server() { 1927 let sandbox = RadrootsCliSandbox::new(); 1928 let proxy = RadrootsdProxyJsonRpcServer::once("proxy_test_token"); 1929 let token_file = radrootsd_proxy_token_file(&sandbox); 1930 sandbox.write_app_config( 1931 format!( 1932 r#"[publish] 1933 transport = "radrootsd_proxy" 1934 1935 [publish.radrootsd_proxy] 1936 url = "{}" 1937 token_file = "{}" 1938 "#, 1939 toml_string(proxy.endpoint()), 1940 toml_string(token_file.display().to_string().as_str()) 1941 ) 1942 .as_str(), 1943 ); 1944 sandbox.json_success(&["--format", "json", "account", "create"]); 1945 let farm = sandbox.json_success(&[ 1946 "--format", 1947 "json", 1948 "farm", 1949 "create", 1950 "--name", 1951 "Proxy Farm", 1952 "--location", 1953 "farmstand", 1954 "--country", 1955 "US", 1956 "--delivery-method", 1957 "pickup", 1958 ]); 1959 let listing_file = create_listing_draft(&sandbox, "radrootsd-proxy-live"); 1960 make_listing_publishable( 1961 &listing_file, 1962 farm["result"]["config"]["farm_d_tag"] 1963 .as_str() 1964 .expect("farm d tag"), 1965 ); 1966 1967 let output = sandbox 1968 .command() 1969 .args([ 1970 "--format", 1971 "json", 1972 "--approval-token", 1973 "approve", 1974 "listing", 1975 "publish", 1976 listing_file.to_string_lossy().as_ref(), 1977 ]) 1978 .output() 1979 .expect("run proxy listing publish"); 1980 let request = proxy.join(); 1981 let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); 1982 1983 assert!( 1984 output.status.success(), 1985 "stderr `{}` stdout `{}`", 1986 String::from_utf8_lossy(&output.stderr), 1987 String::from_utf8_lossy(&output.stdout) 1988 ); 1989 assert_eq!(value["operation_id"], "listing.publish"); 1990 assert_eq!(value["result"]["state"], "published"); 1991 assert_eq!( 1992 value["result"]["source"], 1993 "SDK listing publish ยท configured signer" 1994 ); 1995 assert_eq!(value["result"]["dry_run"], false); 1996 assert_eq!( 1997 request["params"]["event"]["id"], 1998 value["result"]["event_id"] 1999 ); 2000 assert_eq!( 2001 request["params"]["relays"] 2002 .as_array() 2003 .expect("relays") 2004 .len(), 2005 0 2006 ); 2007 assert!( 2008 request["params"]["idempotency_key"] 2009 .as_str() 2010 .expect("daemon idempotency key") 2011 .contains("-1-") 2012 ); 2013 2014 let status = sandbox.json_success(&["--format", "json", "sync", "status", "get"]); 2015 assert_eq!( 2016 status["result"]["source"], 2017 "SDK canonical event store and outbox" 2018 ); 2019 assert_eq!(status["result"]["queue"]["pending_count"], 0); 2020 assert_eq!(status["result"]["queue"]["ready_signed_count"], 0); 2021 assert_eq!(status["result"]["queue"]["publishing_count"], 0); 2022 assert_eq!(status["result"]["queue"]["retryable_count"], 0); 2023 assert_eq!(status["result"]["queue"]["failed_terminal_count"], 0); 2024 } 2025 2026 #[test] 2027 fn direct_relay_listing_publish_uses_myc_nip46_sdk_signer() { 2028 let sandbox = RadrootsCliSandbox::new(); 2029 let user_identity = identity_secret(90); 2030 let client_identity = identity_secret(91); 2031 let remote_identity = identity_secret(92); 2032 let user_public = user_identity.to_public(); 2033 let user_keys = nostr_keys_from_identity(&user_identity); 2034 let remote_keys = nostr_keys_from_identity(&remote_identity); 2035 let remote_pubkey = remote_keys.public_key().to_hex(); 2036 let relay = Nip46RelayServer::new( 2037 remote_keys.clone(), 2038 user_keys, 2039 Nip46RelayFinish::ProductPublish, 2040 ); 2041 let relay_endpoint = relay.endpoint().to_owned(); 2042 let public_identity_file = 2043 write_public_identity_profile(&sandbox, "myc-direct-user", &user_public); 2044 let imported = sandbox.json_success(&[ 2045 "--format", 2046 "json", 2047 "--approval-token", 2048 "approve", 2049 "account", 2050 "import", 2051 "--default", 2052 public_identity_file.to_string_lossy().as_ref(), 2053 ]); 2054 let account_id = imported["result"]["account"]["id"] 2055 .as_str() 2056 .expect("account id"); 2057 store_test_session_secret( 2058 &sandbox, 2059 "session_ready", 2060 client_identity.secret_key_hex().as_str(), 2061 ); 2062 sandbox.write_app_config( 2063 myc_nip46_config( 2064 remote_pubkey.as_str(), 2065 relay_endpoint.as_str(), 2066 account_id, 2067 "session_ready", 2068 ) 2069 .as_str(), 2070 ); 2071 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 2072 assert_eq!(signer["result"]["state"], "ready"); 2073 assert_eq!(signer["result"]["binding"]["state"], "ready"); 2074 let farm = sandbox.json_success(&[ 2075 "--format", 2076 "json", 2077 "farm", 2078 "create", 2079 "--name", 2080 "Myc Relay Farm", 2081 "--location", 2082 "farmstand", 2083 "--country", 2084 "US", 2085 "--delivery-method", 2086 "pickup", 2087 ]); 2088 let listing_file = create_listing_draft(&sandbox, "myc-direct-relay-live"); 2089 make_listing_publishable( 2090 &listing_file, 2091 farm["result"]["config"]["farm_d_tag"] 2092 .as_str() 2093 .expect("farm d tag"), 2094 ); 2095 2096 let output = sandbox 2097 .command() 2098 .args([ 2099 "--format", 2100 "json", 2101 "--approval-token", 2102 "approve", 2103 "--relay", 2104 relay_endpoint.as_str(), 2105 "listing", 2106 "publish", 2107 listing_file.to_string_lossy().as_ref(), 2108 ]) 2109 .output() 2110 .expect("run myc direct listing publish"); 2111 let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); 2112 2113 assert!( 2114 output.status.success(), 2115 "stderr `{}` stdout `{}`", 2116 String::from_utf8_lossy(&output.stderr), 2117 String::from_utf8_lossy(&output.stdout) 2118 ); 2119 let report = relay.join(); 2120 assert_eq!(value["operation_id"], "listing.publish"); 2121 assert_eq!(value["result"]["state"], "published"); 2122 assert_eq!( 2123 value["result"]["source"], 2124 "SDK listing publish ยท configured signer" 2125 ); 2126 assert_eq!(value["result"]["target_relays"][0], relay_endpoint); 2127 assert!(report.connection_count >= 1); 2128 assert!(report.req_count >= 1); 2129 assert_eq!(report.sign_request_count, 1); 2130 assert_eq!(report.published_events.len(), 1); 2131 let published = &report.published_events[0]; 2132 assert_eq!(published.kind, Kind::Custom(KIND_LISTING as u16)); 2133 assert_eq!(published.pubkey.to_hex(), user_public.public_key_hex); 2134 assert_eq!( 2135 published.id.to_hex(), 2136 value["result"]["event_id"].as_str().expect("event id") 2137 ); 2138 } 2139 2140 #[test] 2141 fn radrootsd_proxy_listing_publish_uses_myc_nip46_sdk_signer() { 2142 let sandbox = RadrootsCliSandbox::new(); 2143 let user_identity = identity_secret(93); 2144 let client_identity = identity_secret(94); 2145 let remote_identity = identity_secret(95); 2146 let user_public = user_identity.to_public(); 2147 let user_keys = nostr_keys_from_identity(&user_identity); 2148 let remote_keys = nostr_keys_from_identity(&remote_identity); 2149 let remote_pubkey = remote_keys.public_key().to_hex(); 2150 let relay = Nip46RelayServer::new( 2151 remote_keys.clone(), 2152 user_keys, 2153 Nip46RelayFinish::SignResponse, 2154 ); 2155 let proxy = RadrootsdProxyJsonRpcServer::once("proxy_test_token"); 2156 let token_file = radrootsd_proxy_token_file(&sandbox); 2157 let public_identity_file = 2158 write_public_identity_profile(&sandbox, "myc-proxy-user", &user_public); 2159 let imported = sandbox.json_success(&[ 2160 "--format", 2161 "json", 2162 "--approval-token", 2163 "approve", 2164 "account", 2165 "import", 2166 "--default", 2167 public_identity_file.to_string_lossy().as_ref(), 2168 ]); 2169 let account_id = imported["result"]["account"]["id"] 2170 .as_str() 2171 .expect("account id"); 2172 store_test_session_secret( 2173 &sandbox, 2174 "session_ready", 2175 client_identity.secret_key_hex().as_str(), 2176 ); 2177 let config = format!( 2178 r#"[publish] 2179 transport = "radrootsd_proxy" 2180 2181 [publish.radrootsd_proxy] 2182 url = "{}" 2183 token_file = "{}" 2184 2185 {}"#, 2186 toml_string(proxy.endpoint()), 2187 toml_string(token_file.display().to_string().as_str()), 2188 myc_nip46_config( 2189 remote_pubkey.as_str(), 2190 relay.endpoint(), 2191 account_id, 2192 "session_ready", 2193 ), 2194 ); 2195 sandbox.write_app_config(config.as_str()); 2196 let farm = sandbox.json_success(&[ 2197 "--format", 2198 "json", 2199 "farm", 2200 "create", 2201 "--name", 2202 "Myc Proxy Farm", 2203 "--location", 2204 "farmstand", 2205 "--country", 2206 "US", 2207 "--delivery-method", 2208 "pickup", 2209 ]); 2210 let listing_file = create_listing_draft(&sandbox, "myc-radrootsd-proxy-live"); 2211 make_listing_publishable( 2212 &listing_file, 2213 farm["result"]["config"]["farm_d_tag"] 2214 .as_str() 2215 .expect("farm d tag"), 2216 ); 2217 2218 let output = sandbox 2219 .command() 2220 .args([ 2221 "--format", 2222 "json", 2223 "--approval-token", 2224 "approve", 2225 "listing", 2226 "publish", 2227 listing_file.to_string_lossy().as_ref(), 2228 ]) 2229 .output() 2230 .expect("run myc proxy listing publish"); 2231 let value: Value = serde_json::from_slice(&output.stdout).expect("json output"); 2232 2233 assert!( 2234 output.status.success(), 2235 "stderr `{}` stdout `{}`", 2236 String::from_utf8_lossy(&output.stderr), 2237 String::from_utf8_lossy(&output.stdout) 2238 ); 2239 let report = relay.join(); 2240 let request = proxy.join(); 2241 assert_eq!(value["operation_id"], "listing.publish"); 2242 assert_eq!(value["result"]["state"], "published"); 2243 assert_eq!( 2244 value["result"]["source"], 2245 "SDK listing publish ยท configured signer" 2246 ); 2247 assert!(report.connection_count >= 1); 2248 assert!(report.req_count >= 1); 2249 assert_eq!(report.sign_request_count, 1); 2250 assert_eq!(report.published_events.len(), 0); 2251 assert_eq!(request["params"]["event"]["kind"], KIND_LISTING); 2252 assert_eq!( 2253 request["params"]["event"]["pubkey"], 2254 user_public.public_key_hex 2255 ); 2256 assert_eq!( 2257 request["params"]["event"]["id"], 2258 value["result"]["event_id"] 2259 ); 2260 } 2261 2262 #[test] 2263 fn listing_update_publish_attempts_direct_relay_with_approval() { 2264 let sandbox = RadrootsCliSandbox::new(); 2265 sandbox.json_success(&["--format", "json", "account", "create"]); 2266 let farm = sandbox.json_success(&[ 2267 "--format", 2268 "json", 2269 "farm", 2270 "create", 2271 "--name", 2272 "Update Farm", 2273 "--location", 2274 "farmstand", 2275 "--country", 2276 "US", 2277 "--delivery-method", 2278 "pickup", 2279 ]); 2280 let listing_file = create_listing_draft(&sandbox, "update-unavailable"); 2281 make_listing_publishable( 2282 &listing_file, 2283 farm["result"]["config"]["farm_d_tag"] 2284 .as_str() 2285 .expect("farm d tag"), 2286 ); 2287 2288 let (output, value) = sandbox.json_output(&[ 2289 "--format", 2290 "json", 2291 "--relay", 2292 "ws://127.0.0.1:9", 2293 "--approval-token", 2294 "approve", 2295 "listing", 2296 "update", 2297 listing_file.to_string_lossy().as_ref(), 2298 ]); 2299 2300 assert!(!output.status.success()); 2301 assert_eq!(value["operation_id"], "listing.update"); 2302 assert_eq!(value["result"], Value::Null); 2303 assert_eq!(value["errors"][0]["code"], "network_unavailable"); 2304 assert_eq!(value["errors"][0]["detail"]["class"], "network"); 2305 assert_contains( 2306 &value["errors"][0]["message"], 2307 "SDK relay publish did not reach accepted quorum", 2308 ); 2309 assert!( 2310 !value["errors"][0]["message"] 2311 .as_str() 2312 .expect("error message") 2313 .contains("not implemented") 2314 ); 2315 assert_no_removed_command_reference(&value, &["listing", "update"]); 2316 assert_no_daemon_runtime_reference(&value, &["listing", "update"]); 2317 } 2318 2319 #[test] 2320 fn removed_order_submit_watch_flag_is_rejected_publicly() { 2321 let output = radroots() 2322 .args(["order", "submit", "--watch"]) 2323 .output() 2324 .expect("run removed order submit watch flag"); 2325 2326 assert!(!output.status.success()); 2327 let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); 2328 assert!(stderr.contains("unexpected argument") || stderr.contains("unrecognized")); 2329 } 2330 2331 #[test] 2332 fn removed_command_families_are_rejected_publicly() { 2333 for command in [ 2334 "setup", "status", "doctor", "sell", "find", "local", "net", "myc", "rpc", "product", 2335 "runtime", "job", "message", "approval", "agent", 2336 ] { 2337 let output = radroots() 2338 .arg(command) 2339 .output() 2340 .expect("run removed command"); 2341 2342 assert!(!output.status.success(), "`{command}` should be rejected"); 2343 let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); 2344 assert!(stderr.contains("unrecognized subcommand")); 2345 } 2346 } 2347 2348 #[test] 2349 fn seller_order_decision_and_status_commands_are_public() { 2350 for (operation_id, args) in [ 2351 ( 2352 "order.accept", 2353 [ 2354 "--format", 2355 "json", 2356 "--dry-run", 2357 "order", 2358 "accept", 2359 "ord_public", 2360 ] 2361 .as_slice(), 2362 ), 2363 ( 2364 "order.decline", 2365 [ 2366 "--format", 2367 "json", 2368 "--dry-run", 2369 "order", 2370 "decline", 2371 "ord_public", 2372 "--reason", 2373 "out_of_stock", 2374 ] 2375 .as_slice(), 2376 ), 2377 ( 2378 "order.cancel", 2379 [ 2380 "--format", 2381 "json", 2382 "--dry-run", 2383 "order", 2384 "cancel", 2385 "ord_public", 2386 "--reason", 2387 "changed plans", 2388 ] 2389 .as_slice(), 2390 ), 2391 ( 2392 "order.status.get", 2393 ["--format", "json", "order", "status", "get", "ord_public"].as_slice(), 2394 ), 2395 ] { 2396 let output = radroots() 2397 .args(args) 2398 .output() 2399 .expect("run seller order command"); 2400 let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); 2401 2402 assert_eq!(value["operation_id"], operation_id); 2403 assert_ne!( 2404 String::from_utf8(output.stderr).expect("utf8 stderr"), 2405 "unrecognized subcommand" 2406 ); 2407 } 2408 2409 let output = radroots() 2410 .args(["order", "decision", "accept", "ord_deferred"]) 2411 .output() 2412 .expect("run removed nested decision command"); 2413 2414 assert!(!output.status.success()); 2415 let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); 2416 assert!(stderr.contains("unrecognized subcommand")); 2417 } 2418 2419 #[test] 2420 fn removed_order_post_agreement_subcommands_are_rejected_publicly() { 2421 for args in [ 2422 ["order", "fulfillment", "update", "ord_public"].as_slice(), 2423 ["order", "receipt", "record", "ord_public"].as_slice(), 2424 ["order", "payment", "record", "ord_public"].as_slice(), 2425 ["order", "settlement", "accept", "ord_public"].as_slice(), 2426 ["order", "settlement", "reject", "ord_public"].as_slice(), 2427 ] { 2428 let output = radroots() 2429 .args(args) 2430 .output() 2431 .expect("run removed order command"); 2432 2433 assert!(!output.status.success(), "`{args:?}` should be rejected"); 2434 let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); 2435 assert!(stderr.contains("unrecognized subcommand")); 2436 } 2437 } 2438 2439 #[test] 2440 fn target_outputs_do_not_suggest_removed_command_families() { 2441 let sandbox = RadrootsCliSandbox::new(); 2442 2443 for args in [ 2444 ["--format", "json", "market", "product", "search", "eggs"].as_slice(), 2445 ["--format", "json", "market", "listing", "get", "eggs"].as_slice(), 2446 ["--format", "json", "listing", "get", "eggs"].as_slice(), 2447 ["--format", "json", "listing", "list"].as_slice(), 2448 [ 2449 "--format", 2450 "json", 2451 "order", 2452 "get", 2453 "ord_AAAAAAAAAAAAAAAAAAAAAA", 2454 ] 2455 .as_slice(), 2456 ] { 2457 let value = sandbox.json_success(args); 2458 assert_no_removed_command_reference(&value, args); 2459 } 2460 2461 let sync_args = ["--format", "json", "sync", "status", "get"]; 2462 let value = sandbox.json_success(&sync_args); 2463 assert_eq!(value["operation_id"], "sync.status.get"); 2464 assert_eq!( 2465 value["result"]["source"], 2466 "SDK canonical event store and outbox" 2467 ); 2468 assert_no_removed_command_reference(&value, &sync_args); 2469 } 2470 2471 #[test] 2472 fn listing_list_reports_empty_local_draft_state_truthfully() { 2473 let sandbox = RadrootsCliSandbox::new(); 2474 let value = sandbox.json_success(&["--format", "json", "listing", "list"]); 2475 2476 assert_eq!(value["operation_id"], "listing.list"); 2477 assert_eq!(value["result"]["state"], "empty"); 2478 assert_eq!(value["result"]["count"], 0); 2479 assert_eq!( 2480 value["result"]["listings"] 2481 .as_array() 2482 .expect("listings") 2483 .len(), 2484 0 2485 ); 2486 assert!( 2487 value["result"]["draft_dir"] 2488 .as_str() 2489 .expect("draft dir") 2490 .ends_with("listings/drafts") 2491 ); 2492 assert_no_removed_command_reference(&value, &["listing", "list"]); 2493 } 2494 2495 #[test] 2496 fn listing_list_reports_default_local_drafts() { 2497 let sandbox = RadrootsCliSandbox::new(); 2498 sandbox.json_success(&["--format", "json", "account", "create"]); 2499 sandbox.json_success(&[ 2500 "--format", 2501 "json", 2502 "farm", 2503 "create", 2504 "--name", 2505 "Green Farm", 2506 "--location", 2507 "farmstand", 2508 "--country", 2509 "US", 2510 "--delivery-method", 2511 "pickup", 2512 ]); 2513 let create = sandbox.json_success(&[ 2514 "--format", 2515 "json", 2516 "listing", 2517 "create", 2518 "--key", 2519 "eggs", 2520 "--title", 2521 "Eggs", 2522 "--category", 2523 "eggs", 2524 "--summary", 2525 "Fresh eggs", 2526 "--bin-id", 2527 "bin-1", 2528 "--quantity-amount", 2529 "1", 2530 "--quantity-unit", 2531 "each", 2532 "--price-amount", 2533 "6", 2534 "--price-currency", 2535 "USD", 2536 "--price-per-amount", 2537 "1", 2538 "--price-per-unit", 2539 "each", 2540 "--available", 2541 "10", 2542 ]); 2543 let listing_file = create["result"]["file"].as_str().expect("listing file"); 2544 assert!(Path::new(listing_file).exists()); 2545 2546 let value = sandbox.json_success(&["--format", "json", "listing", "list"]); 2547 let listing = &value["result"]["listings"][0]; 2548 2549 assert_eq!(value["operation_id"], "listing.list"); 2550 assert_eq!(value["result"]["state"], "ready"); 2551 assert_eq!(value["result"]["count"], 1); 2552 assert_eq!(listing["id"], create["result"]["listing_id"]); 2553 assert_eq!(listing["state"], "ready"); 2554 assert_eq!(listing["file"], listing_file); 2555 assert_eq!(listing["product_key"], "eggs"); 2556 assert_eq!(listing["title"], "Eggs"); 2557 assert_eq!(listing["category"], "eggs"); 2558 assert_eq!(listing["location_primary"], "farmstand"); 2559 assert!(listing["seller_pubkey"].is_string()); 2560 assert!(listing["farm_d_tag"].is_string()); 2561 assert_no_removed_command_reference(&value, &["listing", "list"]); 2562 } 2563 2564 #[test] 2565 fn listing_rebind_updates_seller_actor_with_approval() { 2566 let sandbox = RadrootsCliSandbox::new(); 2567 let first = sandbox.json_success(&["--format", "json", "account", "create"]); 2568 let first_account_id = first["result"]["account"]["id"] 2569 .as_str() 2570 .expect("first account id"); 2571 let listing_file = create_listing_draft(&sandbox, "rebind-listing"); 2572 make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); 2573 let initial_validation = sandbox.json_success(&[ 2574 "--format", 2575 "json", 2576 "listing", 2577 "validate", 2578 listing_file.to_string_lossy().as_ref(), 2579 ]); 2580 let first_pubkey = initial_validation["result"]["seller_pubkey"] 2581 .as_str() 2582 .expect("first pubkey"); 2583 let before = fs::read_to_string(&listing_file).expect("listing before"); 2584 let second = sandbox.json_success(&["--format", "json", "account", "create"]); 2585 let second_account_id = second["result"]["account"]["id"] 2586 .as_str() 2587 .expect("second account id"); 2588 2589 let dry_run = sandbox.json_success(&[ 2590 "--format", 2591 "json", 2592 "--dry-run", 2593 "listing", 2594 "rebind", 2595 listing_file.to_string_lossy().as_ref(), 2596 second_account_id, 2597 "--farm-d-tag", 2598 "AAAAAAAAAAAAAAAAAAAAAw", 2599 ]); 2600 assert_eq!(dry_run["operation_id"], "listing.rebind"); 2601 assert_eq!(dry_run["result"]["state"], "dry_run"); 2602 assert_eq!( 2603 dry_run["result"]["from_seller_account_id"], 2604 first_account_id 2605 ); 2606 assert_eq!(dry_run["result"]["from_seller_pubkey"], first_pubkey); 2607 assert_eq!(dry_run["result"]["to_seller_account_id"], second_account_id); 2608 let second_pubkey = dry_run["result"]["to_seller_pubkey"] 2609 .as_str() 2610 .expect("second pubkey"); 2611 assert_eq!(dry_run["result"]["seller_pubkey_changed"], true); 2612 assert_eq!( 2613 fs::read_to_string(&listing_file).expect("listing after dry-run"), 2614 before 2615 ); 2616 2617 let unapproved = sandbox.json_output(&[ 2618 "--format", 2619 "json", 2620 "listing", 2621 "rebind", 2622 listing_file.to_string_lossy().as_ref(), 2623 second_account_id, 2624 "--farm-d-tag", 2625 "AAAAAAAAAAAAAAAAAAAAAw", 2626 ]); 2627 assert!(!unapproved.0.status.success()); 2628 assert_eq!(unapproved.1["errors"][0]["code"], "approval_required"); 2629 2630 let rebound = sandbox.json_success(&[ 2631 "--format", 2632 "json", 2633 "--approval-token", 2634 "approve", 2635 "listing", 2636 "rebind", 2637 listing_file.to_string_lossy().as_ref(), 2638 second_account_id, 2639 "--farm-d-tag", 2640 "AAAAAAAAAAAAAAAAAAAAAw", 2641 ]); 2642 assert_eq!(rebound["operation_id"], "listing.rebind"); 2643 assert_eq!(rebound["result"]["state"], "rebound"); 2644 let after = fs::read_to_string(&listing_file).expect("listing after rebind"); 2645 assert!(after.contains("[seller_actor]")); 2646 assert!(after.contains(second_account_id)); 2647 assert!(after.contains("source = \"listing_rebind\"")); 2648 2649 let validation = sandbox.json_success(&[ 2650 "--format", 2651 "json", 2652 "listing", 2653 "validate", 2654 listing_file.to_string_lossy().as_ref(), 2655 ]); 2656 assert_eq!(validation["result"]["valid"], true); 2657 assert_eq!(validation["result"]["seller_account_id"], second_account_id); 2658 assert_eq!(validation["result"]["seller_pubkey"], second_pubkey); 2659 } 2660 2661 #[test] 2662 fn account_id_global_populates_envelope_actor() { 2663 let output = radroots() 2664 .args([ 2665 "--format", 2666 "json", 2667 "--account-id", 2668 "acct_test", 2669 "workspace", 2670 "get", 2671 ]) 2672 .output() 2673 .expect("run workspace get"); 2674 2675 assert!(output.status.success()); 2676 let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); 2677 2678 assert_eq!(value["operation_id"], "workspace.get"); 2679 assert_eq!(value["actor"]["account_id"], "acct_test"); 2680 assert_eq!(value["actor"]["role"], "account"); 2681 } 2682 2683 #[test] 2684 fn target_command_outputs_standard_json_envelope() { 2685 let output = radroots() 2686 .args(["--format", "json", "workspace", "get"]) 2687 .output() 2688 .expect("run workspace get"); 2689 2690 assert!(output.status.success()); 2691 assert!(output.stderr.is_empty()); 2692 let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); 2693 2694 assert_eq!(value["schema_version"], "radroots.cli.output.v1"); 2695 assert_eq!(value["operation_id"], "workspace.get"); 2696 assert_eq!(value["kind"], "workspace.get"); 2697 assert_eq!(value["dry_run"], false); 2698 assert_eq!(value["errors"].as_array().expect("errors").len(), 0); 2699 } 2700 2701 #[test] 2702 fn next_actions_mirror_result_actions_for_json_and_ndjson() { 2703 let sandbox = RadrootsCliSandbox::new(); 2704 2705 let value = sandbox.json_success(&["--format", "json", "market", "refresh"]); 2706 2707 assert_eq!(value["result"]["actions"][0], "radroots store init"); 2708 assert_eq!(value["next_actions"][0]["label"], "store init"); 2709 assert_eq!(value["next_actions"][0]["command"], "radroots store init"); 2710 2711 let output = sandbox 2712 .command() 2713 .args(["--format", "ndjson", "market", "refresh"]) 2714 .output() 2715 .expect("run market refresh ndjson"); 2716 let frames = ndjson_from_stdout(&output); 2717 let terminal = frames.last().expect("terminal ndjson frame"); 2718 2719 assert!(output.status.success()); 2720 assert_eq!( 2721 terminal["payload"]["next_actions"][0]["command"], 2722 "radroots store init" 2723 ); 2724 2725 for args in [ 2726 &["--format", "ndjson", "config", "get"][..], 2727 &["--format", "ndjson", "health", "status", "get"][..], 2728 &["--format", "ndjson", "health", "check", "run"][..], 2729 ] { 2730 let daemon = RadrootsCliSandbox::new(); 2731 daemon.write_app_config("[publish]\ntransport = \"radrootsd_proxy\"\n"); 2732 let output = daemon.command().args(args).output().expect("run ndjson"); 2733 let frames = ndjson_from_stdout(&output); 2734 let terminal = frames.last().expect("terminal ndjson frame"); 2735 2736 assert!(output.status.success(), "{args:?}"); 2737 assert!( 2738 terminal["payload"]["next_actions"] 2739 .as_array() 2740 .expect("next actions") 2741 .iter() 2742 .any(|action| action["description"] 2743 == "configure RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_PROXY_TOKEN_SECRET_ID"), 2744 "{args:?}" 2745 ); 2746 } 2747 } 2748 2749 #[test] 2750 fn default_human_output_is_concise_and_not_json() { 2751 let output = radroots() 2752 .args(["workspace", "get"]) 2753 .output() 2754 .expect("run workspace get"); 2755 2756 assert!(output.status.success()); 2757 let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); 2758 2759 assert!(stdout.starts_with("workspace.get: ok\n")); 2760 assert!(stdout.contains("request_id: req_workspace_get_")); 2761 assert!(serde_json::from_str::<Value>(&stdout).is_err()); 2762 } 2763 2764 #[test] 2765 fn human_health_status_surfaces_publish_reason_and_actions() { 2766 let sandbox = RadrootsCliSandbox::new(); 2767 2768 let output = sandbox 2769 .command() 2770 .args(["--relay", "ws://127.0.0.1:19007", "health", "status", "get"]) 2771 .output() 2772 .expect("run human health status"); 2773 2774 assert!(output.status.success()); 2775 let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); 2776 2777 assert!(stdout.starts_with("health.status.get: needs_attention\n")); 2778 assert!(stdout.contains("publish_state: unconfigured")); 2779 assert!(stdout.contains("reason: direct_nostr_relay publish transport requires a selected or default write-capable local account for signed writes")); 2780 assert!(stdout.contains("- radroots account create")); 2781 assert!(serde_json::from_str::<Value>(&stdout).is_err()); 2782 } 2783 2784 #[test] 2785 fn human_market_refresh_missing_store_shows_action() { 2786 let sandbox = RadrootsCliSandbox::new(); 2787 2788 let output = sandbox 2789 .command() 2790 .args(["market", "refresh"]) 2791 .output() 2792 .expect("run human market refresh"); 2793 2794 assert!(output.status.success()); 2795 let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); 2796 2797 assert!(stdout.starts_with("market.refresh: unconfigured\n")); 2798 assert!(stdout.contains("reason: local replica database is not initialized")); 2799 assert!(stdout.contains("- radroots store init")); 2800 assert!(serde_json::from_str::<Value>(&stdout).is_err()); 2801 } 2802 2803 #[test] 2804 fn human_failure_output_preserves_error_code_and_message() { 2805 let output = radroots() 2806 .args(["--format", "human", "order", "submit"]) 2807 .output() 2808 .expect("run order submit"); 2809 2810 assert_eq!(output.status.code(), Some(6)); 2811 let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); 2812 2813 assert!(stdout.starts_with("order.submit: error\n")); 2814 assert!(stdout.contains("request_id: req_order_submit_")); 2815 assert!(stdout.contains("error: approval_required")); 2816 assert!(stdout.contains("message: missing required `approval_token` input")); 2817 assert!(serde_json::from_str::<Value>(&stdout).is_err()); 2818 } 2819 2820 #[test] 2821 fn human_failure_output_renders_structured_error_detail() { 2822 let output = radroots() 2823 .args([ 2824 "--format", 2825 "human", 2826 "order", 2827 "event", 2828 "watch", 2829 "ord_missing", 2830 ]) 2831 .output() 2832 .expect("run order event watch"); 2833 2834 assert_eq!(output.status.code(), Some(3)); 2835 let stdout = String::from_utf8(output.stdout).expect("utf8 stdout"); 2836 2837 assert!(stdout.starts_with("order.event.watch: error\n")); 2838 assert!(stdout.contains("request_id: req_order_event_watch_")); 2839 assert!(stdout.contains("error: not_implemented")); 2840 assert!(stdout.contains("state: not_implemented")); 2841 assert!(stdout.contains("reason: relay-backed order event watch is not implemented")); 2842 assert!(stdout.contains("- radroots order status get ord_missing")); 2843 assert!(serde_json::from_str::<Value>(&stdout).is_err()); 2844 } 2845 2846 #[test] 2847 fn request_ids_are_invocation_unique_and_preserve_caller_fields() { 2848 let first = radroots() 2849 .args([ 2850 "--format", 2851 "json", 2852 "--correlation-id", 2853 "corr_test", 2854 "--idempotency-key", 2855 "idem_test", 2856 "workspace", 2857 "get", 2858 ]) 2859 .output() 2860 .expect("run first workspace get"); 2861 let second = radroots() 2862 .args([ 2863 "--format", 2864 "json", 2865 "--correlation-id", 2866 "corr_test", 2867 "--idempotency-key", 2868 "idem_test", 2869 "workspace", 2870 "get", 2871 ]) 2872 .output() 2873 .expect("run second workspace get"); 2874 2875 assert!(first.status.success()); 2876 assert!(second.status.success()); 2877 let first: Value = serde_json::from_slice(&first.stdout).expect("first json envelope"); 2878 let second: Value = serde_json::from_slice(&second.stdout).expect("second json envelope"); 2879 2880 assert_eq!(first["correlation_id"], "corr_test"); 2881 assert_eq!(first["idempotency_key"], "idem_test"); 2882 assert_eq!(second["correlation_id"], "corr_test"); 2883 assert_eq!(second["idempotency_key"], "idem_test"); 2884 assert!( 2885 first["request_id"] 2886 .as_str() 2887 .expect("first request id") 2888 .starts_with("req_workspace_get_") 2889 ); 2890 assert_ne!(first["request_id"], second["request_id"]); 2891 } 2892 2893 #[test] 2894 fn supported_ndjson_outputs_started_and_completed_frames() { 2895 let sandbox = RadrootsCliSandbox::new(); 2896 let output = sandbox 2897 .command() 2898 .args(["--format", "ndjson", "account", "list"]) 2899 .output() 2900 .expect("run account list ndjson"); 2901 2902 assert!(output.status.success()); 2903 let frames = ndjson_from_stdout(&output); 2904 2905 assert_eq!(frames.len(), 2); 2906 assert_eq!(frames[0]["schema_version"], "radroots.cli.output.v1"); 2907 assert_eq!(frames[0]["operation_id"], "account.list"); 2908 assert_eq!(frames[0]["frame_type"], "started"); 2909 assert_eq!(frames[0]["sequence"], 0); 2910 assert_eq!(frames[1]["operation_id"], "account.list"); 2911 assert_eq!(frames[1]["frame_type"], "completed"); 2912 assert_eq!(frames[1]["sequence"], 1); 2913 assert_eq!(frames[1]["errors"].as_array().expect("errors").len(), 0); 2914 assert_eq!(frames[0]["request_id"], frames[1]["request_id"]); 2915 } 2916 2917 #[test] 2918 fn unsupported_ndjson_returns_structured_invalid_input() { 2919 let output = radroots() 2920 .args(["--format", "ndjson", "workspace", "get"]) 2921 .output() 2922 .expect("run workspace get ndjson"); 2923 2924 assert_eq!(output.status.code(), Some(2)); 2925 let frames = ndjson_from_stdout(&output); 2926 2927 assert_eq!(frames.len(), 2); 2928 assert_eq!(frames[0]["operation_id"], "workspace.get"); 2929 assert_eq!(frames[0]["frame_type"], "started"); 2930 assert_eq!(frames[1]["operation_id"], "workspace.get"); 2931 assert_eq!(frames[1]["frame_type"], "error"); 2932 assert_eq!(frames[1]["errors"][0]["code"], "invalid_input"); 2933 assert_eq!(frames[1]["errors"][0]["exit_code"], 2); 2934 2935 let watch_output = radroots() 2936 .args([ 2937 "--format", 2938 "ndjson", 2939 "order", 2940 "event", 2941 "watch", 2942 "ord_missing", 2943 ]) 2944 .output() 2945 .expect("run order event watch ndjson"); 2946 2947 assert_eq!(watch_output.status.code(), Some(2)); 2948 let watch_frames = ndjson_from_stdout(&watch_output); 2949 assert_eq!(watch_frames.len(), 2); 2950 assert_eq!(watch_frames[0]["operation_id"], "order.event.watch"); 2951 assert_eq!(watch_frames[0]["frame_type"], "started"); 2952 assert_eq!(watch_frames[1]["operation_id"], "order.event.watch"); 2953 assert_eq!(watch_frames[1]["frame_type"], "error"); 2954 assert_eq!(watch_frames[1]["errors"][0]["code"], "invalid_input"); 2955 assert_eq!(watch_frames[1]["errors"][0]["exit_code"], 2); 2956 } 2957 2958 #[test] 2959 fn machine_output_exposes_status_format_resource_and_reason_code() { 2960 let sandbox = RadrootsCliSandbox::new(); 2961 2962 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 2963 assert_eq!(account["status"], "ok"); 2964 assert_eq!(account["output_format"], "json"); 2965 assert_eq!(account["reason_code"], Value::Null); 2966 assert_eq!(account["resource"]["kind"], "account"); 2967 assert_eq!( 2968 account["resource"]["id"], 2969 account["result"]["account"]["id"] 2970 ); 2971 2972 let output = sandbox 2973 .command() 2974 .args(["--format", "json", "--dry-run", "workspace", "get"]) 2975 .output() 2976 .expect("run invalid dry-run"); 2977 assert_eq!(output.status.code(), Some(2)); 2978 let invalid = json_from_stdout(&output); 2979 assert_eq!(invalid["status"], "error"); 2980 assert_eq!(invalid["reason_code"], "invalid_input"); 2981 assert_eq!(invalid["errors"][0]["reason_code"], "invalid_input"); 2982 2983 let ndjson_output = sandbox 2984 .command() 2985 .args(["--format", "ndjson", "--dry-run", "workspace", "get"]) 2986 .output() 2987 .expect("run invalid ndjson"); 2988 assert_eq!(ndjson_output.status.code(), Some(2)); 2989 let frames = ndjson_from_stdout(&ndjson_output); 2990 assert_eq!(frames[0]["payload"]["status"], "error"); 2991 assert_eq!(frames[0]["payload"]["output_format"], "ndjson"); 2992 assert_eq!(frames[1]["payload"]["status"], "error"); 2993 assert_eq!(frames[1]["payload"]["output_format"], "ndjson"); 2994 assert_eq!(frames[1]["payload"]["reason_code"], "invalid_input"); 2995 assert_eq!(frames[1]["errors"][0]["reason_code"], "invalid_input"); 2996 } 2997 2998 #[test] 2999 fn offline_forbids_external_network_operations() { 3000 for (operation_id, args) in [ 3001 ( 3002 "sync.pull", 3003 ["--format", "json", "--offline", "sync", "pull"].as_slice(), 3004 ), 3005 ( 3006 "sync.push", 3007 ["--format", "json", "--offline", "sync", "push"].as_slice(), 3008 ), 3009 ( 3010 "market.refresh", 3011 ["--format", "json", "--offline", "market", "refresh"].as_slice(), 3012 ), 3013 ( 3014 "order.submit", 3015 ["--format", "json", "--offline", "order", "submit"].as_slice(), 3016 ), 3017 ( 3018 "order.cancel", 3019 [ 3020 "--format", 3021 "json", 3022 "--offline", 3023 "order", 3024 "cancel", 3025 "ord_offline_cancel", 3026 "--reason", 3027 "changed plans", 3028 ] 3029 .as_slice(), 3030 ), 3031 ( 3032 "order.revision.propose", 3033 [ 3034 "--format", 3035 "json", 3036 "--offline", 3037 "--approval-token", 3038 "approve", 3039 "order", 3040 "revision", 3041 "propose", 3042 "ord_offline_revision", 3043 "--reason", 3044 "update count", 3045 "--bin-id", 3046 "bin-1", 3047 "--bin-count", 3048 "2", 3049 ] 3050 .as_slice(), 3051 ), 3052 ( 3053 "order.revision.accept", 3054 [ 3055 "--format", 3056 "json", 3057 "--offline", 3058 "--approval-token", 3059 "approve", 3060 "order", 3061 "revision", 3062 "accept", 3063 "ord_offline_revision", 3064 "--revision-id", 3065 "revision_1", 3066 ] 3067 .as_slice(), 3068 ), 3069 ( 3070 "order.revision.decline", 3071 [ 3072 "--format", 3073 "json", 3074 "--offline", 3075 "--approval-token", 3076 "approve", 3077 "order", 3078 "revision", 3079 "decline", 3080 "ord_offline_revision", 3081 "--revision-id", 3082 "revision_1", 3083 "--reason", 3084 "keep original", 3085 ] 3086 .as_slice(), 3087 ), 3088 ] { 3089 let output = radroots() 3090 .args(args) 3091 .output() 3092 .expect("run offline external command"); 3093 3094 assert!(!output.status.success()); 3095 let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); 3096 3097 assert_eq!(value["operation_id"], operation_id); 3098 assert_eq!(value["result"], Value::Null); 3099 assert_eq!(value["errors"][0]["code"], "offline_forbidden"); 3100 assert_eq!(value["errors"][0]["exit_code"], 8); 3101 } 3102 } 3103 3104 #[test] 3105 fn offline_allows_supported_external_dry_run() { 3106 let sandbox = RadrootsCliSandbox::new(); 3107 sandbox.json_success(&["--format", "json", "account", "create"]); 3108 let listing_file = create_listing_draft(&sandbox, "offline-dry-run"); 3109 make_listing_publishable(&listing_file, "AAAAAAAAAAAAAAAAAAAAAw"); 3110 3111 let publish = sandbox.json_success(&[ 3112 "--format", 3113 "json", 3114 "--offline", 3115 "--dry-run", 3116 "listing", 3117 "publish", 3118 listing_file.to_string_lossy().as_ref(), 3119 ]); 3120 3121 assert_eq!(publish["operation_id"], "listing.publish"); 3122 assert_eq!(publish["result"]["state"], "dry_run"); 3123 3124 sandbox.json_success(&["--format", "json", "store", "init"]); 3125 let sync_push = sandbox.json_success(&[ 3126 "--format", 3127 "json", 3128 "--offline", 3129 "--relay", 3130 "ws://127.0.0.1:9", 3131 "--dry-run", 3132 "sync", 3133 "push", 3134 ]); 3135 3136 assert_eq!(sync_push["operation_id"], "sync.push"); 3137 assert_eq!(sync_push["result"]["state"], "ready"); 3138 } 3139 3140 #[test] 3141 fn offline_listing_publish_enqueues_sdk_outbox_without_direct_relay_push() { 3142 let sandbox = RadrootsCliSandbox::new(); 3143 sandbox.json_success(&["--format", "json", "account", "create"]); 3144 let farm = sandbox.json_success(&[ 3145 "--format", 3146 "json", 3147 "farm", 3148 "create", 3149 "--name", 3150 "Offline Farm", 3151 "--location", 3152 "farmstand", 3153 "--country", 3154 "US", 3155 "--delivery-method", 3156 "pickup", 3157 ]); 3158 let farm_d_tag = farm["result"]["config"]["farm_d_tag"] 3159 .as_str() 3160 .expect("farm d tag"); 3161 let listing_file = create_listing_draft(&sandbox, "offline-sdk-enqueue"); 3162 make_listing_publishable(&listing_file, farm_d_tag); 3163 let relay = "ws://127.0.0.1:9"; 3164 let local_event_records_before_publish = sandbox.local_event_records().len(); 3165 3166 let publish = sandbox.json_success(&[ 3167 "--format", 3168 "json", 3169 "--offline", 3170 "--relay", 3171 relay, 3172 "--approval-token", 3173 "approve", 3174 "listing", 3175 "publish", 3176 listing_file.to_string_lossy().as_ref(), 3177 ]); 3178 3179 assert_eq!(publish["operation_id"], "listing.publish"); 3180 assert_eq!(publish["result"]["state"], "queued"); 3181 assert_eq!( 3182 publish["result"]["source"], 3183 "SDK listing publish ยท configured signer" 3184 ); 3185 assert_eq!(publish["result"]["target_relays"][0], relay); 3186 assert_eq!(publish["result"]["actions"][0], "radroots sync push"); 3187 assert_eq!( 3188 publish["result"]["event_id"] 3189 .as_str() 3190 .expect("sdk event id") 3191 .len(), 3192 64 3193 ); 3194 assert!( 3195 sandbox 3196 .root() 3197 .join("data/apps/cli/replica/sdk/outbox.sqlite") 3198 .exists() 3199 ); 3200 assert_eq!( 3201 sandbox.local_event_records().len(), 3202 local_event_records_before_publish 3203 ); 3204 } 3205 3206 #[test] 3207 fn listing_publish_idempotency_conflict_maps_sdk_partial_mutation_recovery() { 3208 let sandbox = RadrootsCliSandbox::new(); 3209 sandbox.json_success(&["--format", "json", "account", "create"]); 3210 let farm = sandbox.json_success(&[ 3211 "--format", 3212 "json", 3213 "farm", 3214 "create", 3215 "--name", 3216 "Conflict Farm", 3217 "--location", 3218 "farmstand", 3219 "--country", 3220 "US", 3221 "--delivery-method", 3222 "pickup", 3223 ]); 3224 let farm_d_tag = farm["result"]["config"]["farm_d_tag"] 3225 .as_str() 3226 .expect("farm d tag"); 3227 let listing_file = create_listing_draft(&sandbox, "idem-conflict"); 3228 make_listing_publishable(&listing_file, farm_d_tag); 3229 let relay = "ws://127.0.0.1:9"; 3230 let idempotency_key = "listing-idem-conflict"; 3231 3232 sandbox.json_success(&[ 3233 "--format", 3234 "json", 3235 "--offline", 3236 "--relay", 3237 relay, 3238 "--approval-token", 3239 "approve", 3240 "--idempotency-key", 3241 idempotency_key, 3242 "listing", 3243 "publish", 3244 listing_file.to_string_lossy().as_ref(), 3245 ]); 3246 let raw = fs::read_to_string(&listing_file).expect("listing draft"); 3247 fs::write( 3248 &listing_file, 3249 raw.replace("title = \"Eggs\"", "title = \"Conflict Eggs\""), 3250 ) 3251 .expect("rewrite listing draft"); 3252 3253 let (output, conflict) = sandbox.json_output(&[ 3254 "--format", 3255 "json", 3256 "--offline", 3257 "--relay", 3258 relay, 3259 "--approval-token", 3260 "approve", 3261 "--idempotency-key", 3262 idempotency_key, 3263 "listing", 3264 "publish", 3265 listing_file.to_string_lossy().as_ref(), 3266 ]); 3267 3268 assert!(!output.status.success()); 3269 assert_eq!(conflict["operation_id"], "listing.publish"); 3270 assert_eq!(conflict["errors"][0]["code"], "partial_local_mutation"); 3271 assert_eq!(conflict["errors"][0]["detail"]["class"], "local_mutation"); 3272 assert_eq!( 3273 conflict["errors"][0]["detail"]["detail"]["failure"], 3274 "outbox_idempotency_conflict" 3275 ); 3276 assert_eq!( 3277 conflict["errors"][0]["detail"]["actions"][0], 3278 "radroots listing publish" 3279 ); 3280 } 3281 3282 #[test] 3283 fn offline_rejects_order_decision_dry_run() { 3284 for (operation_id, args) in [ 3285 ( 3286 "order.accept", 3287 [ 3288 "--format", 3289 "json", 3290 "--offline", 3291 "--dry-run", 3292 "order", 3293 "accept", 3294 "ord_offline_decision", 3295 ] 3296 .as_slice(), 3297 ), 3298 ( 3299 "order.decline", 3300 [ 3301 "--format", 3302 "json", 3303 "--offline", 3304 "--dry-run", 3305 "order", 3306 "decline", 3307 "ord_offline_decision", 3308 "--reason", 3309 "unavailable", 3310 ] 3311 .as_slice(), 3312 ), 3313 ( 3314 "order.cancel", 3315 [ 3316 "--format", 3317 "json", 3318 "--offline", 3319 "--dry-run", 3320 "order", 3321 "cancel", 3322 "ord_offline_decision", 3323 "--reason", 3324 "changed plans", 3325 ] 3326 .as_slice(), 3327 ), 3328 ( 3329 "order.revision.propose", 3330 [ 3331 "--format", 3332 "json", 3333 "--offline", 3334 "--dry-run", 3335 "order", 3336 "revision", 3337 "propose", 3338 "ord_offline_revision", 3339 "--reason", 3340 "update count", 3341 "--bin-id", 3342 "bin-1", 3343 "--bin-count", 3344 "2", 3345 ] 3346 .as_slice(), 3347 ), 3348 ( 3349 "order.revision.accept", 3350 [ 3351 "--format", 3352 "json", 3353 "--offline", 3354 "--dry-run", 3355 "order", 3356 "revision", 3357 "accept", 3358 "ord_offline_revision", 3359 "--revision-id", 3360 "revision_1", 3361 ] 3362 .as_slice(), 3363 ), 3364 ( 3365 "order.revision.decline", 3366 [ 3367 "--format", 3368 "json", 3369 "--offline", 3370 "--dry-run", 3371 "order", 3372 "revision", 3373 "decline", 3374 "ord_offline_revision", 3375 "--revision-id", 3376 "revision_1", 3377 "--reason", 3378 "keep original", 3379 ] 3380 .as_slice(), 3381 ), 3382 ] { 3383 let output = radroots() 3384 .args(args) 3385 .output() 3386 .expect("run offline order decision dry-run"); 3387 let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); 3388 3389 assert_eq!(output.status.code(), Some(8)); 3390 assert_eq!(value["operation_id"], operation_id); 3391 assert_eq!(value["dry_run"], true); 3392 assert_eq!(value["result"], Value::Null); 3393 assert_eq!(value["errors"][0]["code"], "offline_forbidden"); 3394 assert_eq!(value["errors"][0]["exit_code"], 8); 3395 } 3396 } 3397 3398 #[test] 3399 fn listing_publish_dry_run_validates_missing_file() { 3400 let sandbox = RadrootsCliSandbox::new(); 3401 let missing = sandbox.root().join("missing-listing.toml"); 3402 let (output, value) = sandbox.json_output(&[ 3403 "--format", 3404 "json", 3405 "--dry-run", 3406 "listing", 3407 "publish", 3408 missing.to_string_lossy().as_ref(), 3409 ]); 3410 3411 assert!(!output.status.success()); 3412 assert_eq!(value["operation_id"], "listing.publish"); 3413 assert_eq!(value["result"], Value::Null); 3414 assert_eq!(value["errors"][0]["code"], "not_found"); 3415 assert_eq!(value["errors"][0]["exit_code"], 4); 3416 assert_no_removed_command_reference( 3417 &value, 3418 &["listing", "publish", "--dry-run", "missing-listing.toml"], 3419 ); 3420 } 3421 3422 #[test] 3423 fn listing_publish_invalid_draft_returns_validation_failure() { 3424 let sandbox = RadrootsCliSandbox::new(); 3425 let invalid = sandbox.root().join("invalid-listing.toml"); 3426 fs::write(&invalid, "listing = [").expect("write invalid listing"); 3427 3428 let (output, value) = sandbox.json_output(&[ 3429 "--format", 3430 "json", 3431 "--dry-run", 3432 "listing", 3433 "publish", 3434 invalid.to_string_lossy().as_ref(), 3435 ]); 3436 3437 assert!(!output.status.success()); 3438 assert_eq!(value["operation_id"], "listing.publish"); 3439 assert_eq!(value["result"], Value::Null); 3440 assert_eq!(value["errors"][0]["code"], "validation_failed"); 3441 assert_eq!(value["errors"][0]["exit_code"], 10); 3442 } 3443 3444 #[test] 3445 fn online_requires_relay_for_external_network_operations() { 3446 for (operation_id, args) in [ 3447 ( 3448 "sync.pull", 3449 ["--format", "json", "--online", "sync", "pull"].as_slice(), 3450 ), 3451 ( 3452 "sync.push", 3453 ["--format", "json", "--online", "sync", "push"].as_slice(), 3454 ), 3455 ( 3456 "market.refresh", 3457 ["--format", "json", "--online", "market", "refresh"].as_slice(), 3458 ), 3459 ( 3460 "order.event.list", 3461 ["--format", "json", "--online", "order", "event", "list"].as_slice(), 3462 ), 3463 ( 3464 "order.cancel", 3465 [ 3466 "--format", 3467 "json", 3468 "--online", 3469 "order", 3470 "cancel", 3471 "ord_missing", 3472 "--reason", 3473 "changed plans", 3474 ] 3475 .as_slice(), 3476 ), 3477 ( 3478 "order.revision.propose", 3479 [ 3480 "--format", 3481 "json", 3482 "--online", 3483 "--approval-token", 3484 "approve", 3485 "order", 3486 "revision", 3487 "propose", 3488 "ord_missing", 3489 "--reason", 3490 "update count", 3491 "--bin-id", 3492 "bin-1", 3493 "--bin-count", 3494 "2", 3495 ] 3496 .as_slice(), 3497 ), 3498 ( 3499 "order.revision.accept", 3500 [ 3501 "--format", 3502 "json", 3503 "--online", 3504 "--dry-run", 3505 "order", 3506 "revision", 3507 "accept", 3508 "ord_missing", 3509 "--revision-id", 3510 "revision_1", 3511 ] 3512 .as_slice(), 3513 ), 3514 ( 3515 "order.revision.decline", 3516 [ 3517 "--format", 3518 "json", 3519 "--online", 3520 "--dry-run", 3521 "order", 3522 "revision", 3523 "decline", 3524 "ord_missing", 3525 "--revision-id", 3526 "revision_1", 3527 "--reason", 3528 "keep original", 3529 ] 3530 .as_slice(), 3531 ), 3532 ] { 3533 let output = radroots() 3534 .args(args) 3535 .output() 3536 .expect("run online external command"); 3537 3538 assert!(!output.status.success()); 3539 let value: Value = serde_json::from_slice(&output.stdout).expect("json envelope"); 3540 3541 assert_eq!(value["operation_id"], operation_id); 3542 assert_eq!(value["result"], Value::Null); 3543 assert_eq!(value["errors"][0]["code"], "network_unavailable"); 3544 assert_eq!(value["errors"][0]["exit_code"], 8); 3545 assert!( 3546 value["errors"][0]["message"] 3547 .as_str() 3548 .expect("message") 3549 .contains("requires at least one configured relay") 3550 ); 3551 } 3552 } 3553 3554 #[test] 3555 fn order_status_get_uses_sdk_local_projection_without_relay_fetch() { 3556 let sandbox = RadrootsCliSandbox::new(); 3557 let local = sandbox.json_success(&[ 3558 "--format", 3559 "json", 3560 "--online", 3561 "order", 3562 "status", 3563 "get", 3564 "ord_missing", 3565 ]); 3566 3567 assert_eq!(local["operation_id"], "order.status.get"); 3568 assert_eq!(local["result"]["state"], "missing"); 3569 assert_eq!(local["result"]["source"], "SDK local order projection"); 3570 assert_eq!( 3571 local["result"]["actor_context_source"], 3572 "sdk_local_projection" 3573 ); 3574 assert_eq!(local["result"]["fetched_count"], 0); 3575 assert_eq!(local["result"]["decoded_count"], 0); 3576 3577 let listener = TcpListener::bind("127.0.0.1:0").expect("bind closed relay"); 3578 let closed_relay = format!("ws://{}", listener.local_addr().expect("relay addr")); 3579 drop(listener); 3580 let with_closed_relay = sandbox.json_success(&[ 3581 "--format", 3582 "json", 3583 "--relay", 3584 closed_relay.as_str(), 3585 "order", 3586 "status", 3587 "get", 3588 "ord_missing", 3589 ]); 3590 3591 assert_eq!(with_closed_relay["operation_id"], "order.status.get"); 3592 assert_eq!(with_closed_relay["result"]["state"], "missing"); 3593 assert_eq!( 3594 with_closed_relay["result"]["source"], 3595 "SDK local order projection" 3596 ); 3597 assert_eq!(with_closed_relay["result"]["fetched_count"], 0); 3598 assert_eq!(with_closed_relay["result"]["decoded_count"], 0); 3599 } 3600 3601 #[test] 3602 fn order_status_get_invalid_order_id_uses_sdk_error_contract() { 3603 let sandbox = RadrootsCliSandbox::new(); 3604 let (output, value) = 3605 sandbox.json_output(&["--format", "json", "order", "status", "get", "bad order id"]); 3606 3607 assert!(!output.status.success()); 3608 assert_eq!(value["operation_id"], "order.status.get"); 3609 assert_eq!(value["result"], Value::Null); 3610 assert_eq!(value["errors"][0]["code"], "invalid_order_id"); 3611 assert_eq!(value["errors"][0]["exit_code"], 2); 3612 assert_eq!(value["errors"][0]["detail"]["class"], "request"); 3613 assert_eq!(value["errors"][0]["detail"]["retryable"], false); 3614 assert_eq!( 3615 value["errors"][0]["detail"]["detail"]["value"], 3616 "bad order id" 3617 ); 3618 } 3619 3620 #[test] 3621 fn legacy_radrootsd_publish_transport_value_is_rejected() { 3622 let sandbox = RadrootsCliSandbox::new(); 3623 let output = sandbox 3624 .command() 3625 .args(["--publish-transport", "radrootsd", "sync", "push"]) 3626 .output() 3627 .expect("run legacy publish transport"); 3628 let stderr = String::from_utf8(output.stderr).expect("utf8 stderr"); 3629 3630 assert!(!output.status.success()); 3631 assert!(stderr.contains("invalid value")); 3632 assert!(stderr.contains("radrootsd_proxy")); 3633 } 3634 3635 #[test] 3636 fn online_order_event_watch_returns_deferred_without_relay_preflight() { 3637 let sandbox = RadrootsCliSandbox::new(); 3638 let (output, value) = sandbox.json_output(&[ 3639 "--format", 3640 "json", 3641 "--online", 3642 "order", 3643 "event", 3644 "watch", 3645 "ord_missing", 3646 ]); 3647 3648 assert!(!output.status.success()); 3649 assert_eq!(output.status.code(), Some(3)); 3650 assert_eq!(value["operation_id"], "order.event.watch"); 3651 assert_eq!(value["result"], Value::Null); 3652 assert_eq!(value["errors"][0]["code"], "not_implemented"); 3653 assert_eq!(value["errors"][0]["detail"]["state"], "not_implemented"); 3654 assert_eq!(value["errors"][0]["detail"]["order_id"], "ord_missing"); 3655 assert_eq!( 3656 value["next_actions"][0]["command"], 3657 "radroots order status get ord_missing" 3658 ); 3659 assert!( 3660 !value["errors"][0]["message"] 3661 .as_str() 3662 .expect("message") 3663 .contains("configured relay") 3664 ); 3665 assert_no_daemon_runtime_reference(&value, &["order", "event", "watch"]); 3666 } 3667 3668 #[test] 3669 fn online_allows_local_diagnostics() { 3670 let value = RadrootsCliSandbox::new().json_success(&[ 3671 "--format", 3672 "json", 3673 "--online", 3674 "workspace", 3675 "get", 3676 ]); 3677 3678 assert_eq!(value["operation_id"], "workspace.get"); 3679 assert_eq!(value["errors"].as_array().expect("errors").len(), 0); 3680 } 3681 3682 #[test] 3683 fn store_export_dry_run_is_structured_unsupported() { 3684 let sandbox = RadrootsCliSandbox::new(); 3685 let (output, value) = 3686 sandbox.json_output(&["--format", "json", "--dry-run", "store", "export"]); 3687 3688 assert!(!output.status.success()); 3689 assert_eq!(output.status.code(), Some(2)); 3690 assert_eq!(value["operation_id"], "store.export"); 3691 assert_eq!(value["errors"][0]["code"], "invalid_input"); 3692 assert_eq!(value["errors"][0]["exit_code"], 2); 3693 } 3694 3695 #[test] 3696 fn store_backup_and_restore_use_sdk_canonical_store() { 3697 let sandbox = RadrootsCliSandbox::new(); 3698 let sdk_root = sandbox.root().join("data/apps/cli/replica/sdk"); 3699 3700 assert!(!sdk_root.exists()); 3701 3702 let status = sandbox.json_success(&["--format", "json", "store", "status", "get"]); 3703 3704 assert_eq!(status["operation_id"], "store.status.get"); 3705 assert_eq!(status["result"]["state"], "ready"); 3706 assert_eq!( 3707 status["result"]["source"], 3708 "SDK canonical event store and outbox" 3709 ); 3710 assert_eq!(status["result"]["canonical_store"], "sdk"); 3711 assert_eq!(status["result"]["sdk_storage"], "directory"); 3712 assert_eq!(status["result"]["sdk_existed_before_open"], false); 3713 assert_eq!( 3714 status["result"]["event_store"]["store"]["integrity_ok"], 3715 true 3716 ); 3717 assert_eq!(status["result"]["outbox"]["store"]["integrity_ok"], true); 3718 assert_eq!(status["result"]["legacy_replica"]["state"], "unconfigured"); 3719 assert_eq!( 3720 status["result"]["legacy_replica"]["source"], 3721 "legacy local replica ยท derived/migration source" 3722 ); 3723 assert!(sdk_root.join("event_store.sqlite").exists()); 3724 assert!(sdk_root.join("outbox.sqlite").exists()); 3725 3726 let legacy = sandbox.json_success(&["--format", "json", "store", "init"]); 3727 assert_eq!(legacy["operation_id"], "store.init"); 3728 3729 let status_after_legacy = sandbox.json_success(&["--format", "json", "store", "status", "get"]); 3730 3731 assert_eq!( 3732 status_after_legacy["result"]["source"], 3733 "SDK canonical event store and outbox" 3734 ); 3735 assert_eq!( 3736 status_after_legacy["result"]["legacy_replica"]["state"], 3737 "ready" 3738 ); 3739 assert_eq!( 3740 status_after_legacy["result"]["legacy_replica"]["source"], 3741 "legacy local replica ยท derived/migration source" 3742 ); 3743 3744 let dry_run = 3745 sandbox.json_success(&["--format", "json", "--dry-run", "store", "backup", "create"]); 3746 let dry_run_destination = dry_run["result"]["destination"] 3747 .as_str() 3748 .expect("backup destination"); 3749 let dry_run_file = dry_run["result"]["file"].as_str().expect("backup file"); 3750 3751 assert_eq!(dry_run["operation_id"], "store.backup.create"); 3752 assert_eq!(dry_run["dry_run"], true); 3753 assert_eq!(dry_run["result"]["state"], "dry_run"); 3754 assert_eq!( 3755 dry_run["result"]["source"], 3756 "SDK canonical event store and outbox" 3757 ); 3758 assert_eq!(dry_run["result"]["backup_kind"], "sdk_canonical"); 3759 assert_eq!(dry_run["result"]["canonical_store"], "sdk"); 3760 assert_eq!(dry_run["result"]["size_bytes"], 0); 3761 assert_eq!( 3762 dry_run["result"]["manifest"]["manifest_kind"], 3763 "sdk_canonical_backup_preview" 3764 ); 3765 assert_eq!( 3766 dry_run["result"]["manifest"]["backup_verification"]["event_store_ok"], 3767 true 3768 ); 3769 assert_eq!( 3770 dry_run["result"]["manifest"]["backup_verification"]["outbox_ok"], 3771 true 3772 ); 3773 assert!(!Path::new(dry_run_destination).exists()); 3774 assert!(!Path::new(dry_run_file).exists()); 3775 3776 let backup = sandbox.json_success(&["--format", "json", "store", "backup", "create"]); 3777 let backup_destination = backup["result"]["destination"] 3778 .as_str() 3779 .expect("backup destination"); 3780 let event_store_file = backup["result"]["event_store_file"] 3781 .as_str() 3782 .expect("event store backup"); 3783 let outbox_file = backup["result"]["outbox_file"] 3784 .as_str() 3785 .expect("outbox backup"); 3786 let manifest_file = backup["result"]["manifest_file"] 3787 .as_str() 3788 .expect("manifest backup"); 3789 3790 assert_eq!(backup["operation_id"], "store.backup.create"); 3791 assert_eq!(backup["result"]["state"], "completed"); 3792 assert_eq!( 3793 backup["result"]["source"], 3794 "SDK canonical event store and outbox" 3795 ); 3796 assert_eq!(backup["result"]["backup_kind"], "sdk_canonical"); 3797 assert_eq!(backup["result"]["canonical_store"], "sdk"); 3798 assert_eq!( 3799 backup["result"]["manifest"]["manifest_kind"], 3800 "storage_backup" 3801 ); 3802 assert!( 3803 backup["result"]["size_bytes"] 3804 .as_u64() 3805 .expect("backup size") 3806 > 0 3807 ); 3808 assert!(Path::new(backup_destination).exists()); 3809 assert!(Path::new(event_store_file).exists()); 3810 assert!(Path::new(outbox_file).exists()); 3811 assert!(Path::new(manifest_file).exists()); 3812 assert_eq!( 3813 backup["result"]["manifest"]["backup_verification"]["event_store_ok"], 3814 true 3815 ); 3816 assert_eq!( 3817 backup["result"]["manifest"]["backup_verification"]["outbox_ok"], 3818 true 3819 ); 3820 assert!( 3821 backup["result"]["manifest"]["source_paths"]["event_store_path"] 3822 .as_str() 3823 .expect("source event store path") 3824 .contains("data/apps/cli/replica/sdk/event_store.sqlite") 3825 ); 3826 assert!(!event_store_file.ends_with("replica.sqlite")); 3827 3828 let restore_destination = sandbox.root().join("restored-sdk-store"); 3829 let restore_destination_arg = restore_destination.to_string_lossy().to_string(); 3830 let restore_dry_run = sandbox.json_success(&[ 3831 "--format", 3832 "json", 3833 "--dry-run", 3834 "store", 3835 "backup", 3836 "restore", 3837 backup_destination, 3838 "--destination", 3839 restore_destination_arg.as_str(), 3840 ]); 3841 3842 assert_eq!(restore_dry_run["operation_id"], "store.backup.restore"); 3843 assert_eq!(restore_dry_run["dry_run"], true); 3844 assert_eq!(restore_dry_run["result"]["state"], "dry_run"); 3845 assert_eq!( 3846 restore_dry_run["result"]["source"], 3847 "SDK canonical event store and outbox" 3848 ); 3849 assert_eq!(restore_dry_run["result"]["restore_kind"], "sdk_canonical"); 3850 assert_eq!(restore_dry_run["result"]["canonical_store"], "sdk"); 3851 assert_eq!( 3852 restore_dry_run["result"]["backup_source"], 3853 backup_destination 3854 ); 3855 assert_eq!( 3856 restore_dry_run["result"]["destination"], 3857 restore_destination_arg 3858 ); 3859 assert_eq!( 3860 restore_dry_run["result"]["manifest"]["manifest_kind"], 3861 "storage_backup" 3862 ); 3863 assert_eq!( 3864 restore_dry_run["result"]["verification"]["event_store_ok"], 3865 true 3866 ); 3867 assert_eq!(restore_dry_run["result"]["verification"]["outbox_ok"], true); 3868 assert!(!restore_destination.exists()); 3869 3870 let restored = sandbox.json_success(&[ 3871 "--format", 3872 "json", 3873 "store", 3874 "backup", 3875 "restore", 3876 backup_destination, 3877 "--destination", 3878 restore_destination_arg.as_str(), 3879 ]); 3880 3881 assert_eq!(restored["operation_id"], "store.backup.restore"); 3882 assert_eq!(restored["result"]["state"], "completed"); 3883 assert_eq!( 3884 restored["result"]["source"], 3885 "SDK canonical event store and outbox" 3886 ); 3887 assert_eq!(restored["result"]["restore_kind"], "sdk_canonical"); 3888 assert_eq!(restored["result"]["canonical_store"], "sdk"); 3889 assert!(restore_destination.join("event_store.sqlite").exists()); 3890 assert!(restore_destination.join("outbox.sqlite").exists()); 3891 assert_eq!( 3892 restored["result"]["restored_event_store_file"], 3893 restore_destination 3894 .join("event_store.sqlite") 3895 .to_string_lossy() 3896 .to_string() 3897 ); 3898 assert_eq!( 3899 restored["result"]["restored_outbox_file"], 3900 restore_destination 3901 .join("outbox.sqlite") 3902 .to_string_lossy() 3903 .to_string() 3904 ); 3905 3906 let unapproved_overwrite = sandbox.json_output(&[ 3907 "--format", 3908 "json", 3909 "store", 3910 "backup", 3911 "restore", 3912 backup_destination, 3913 "--destination", 3914 restore_destination_arg.as_str(), 3915 "--overwrite", 3916 ]); 3917 assert!(!unapproved_overwrite.0.status.success()); 3918 assert_eq!(unapproved_overwrite.0.status.code(), Some(6)); 3919 assert_eq!( 3920 unapproved_overwrite.1["operation_id"], 3921 "store.backup.restore" 3922 ); 3923 assert_eq!( 3924 unapproved_overwrite.1["errors"][0]["code"], 3925 "approval_required" 3926 ); 3927 3928 let approved_overwrite = sandbox.json_success(&[ 3929 "--format", 3930 "json", 3931 "--approval-token", 3932 "restore-ok", 3933 "store", 3934 "backup", 3935 "restore", 3936 backup_destination, 3937 "--destination", 3938 restore_destination_arg.as_str(), 3939 "--overwrite", 3940 ]); 3941 assert_eq!(approved_overwrite["operation_id"], "store.backup.restore"); 3942 assert_eq!(approved_overwrite["result"]["state"], "completed"); 3943 assert_eq!(approved_overwrite["result"]["overwrite"], true); 3944 3945 let missing_backup = sandbox.root().join("missing-sdk-backup"); 3946 let missing_backup_arg = missing_backup.to_string_lossy().to_string(); 3947 let (missing_output, missing_value) = sandbox.json_output(&[ 3948 "--format", 3949 "json", 3950 "store", 3951 "backup", 3952 "restore", 3953 missing_backup_arg.as_str(), 3954 "--destination", 3955 sandbox 3956 .root() 3957 .join("missing-restore-destination") 3958 .to_string_lossy() 3959 .as_ref(), 3960 ]); 3961 assert!(!missing_output.status.success()); 3962 assert_eq!(missing_value["operation_id"], "store.backup.restore"); 3963 assert_eq!(missing_value["errors"][0]["code"], "io"); 3964 assert_eq!(missing_value["errors"][0]["detail"]["class"], "storage"); 3965 } 3966 3967 #[test] 3968 fn core_account_store_dry_runs_preflight_without_mutating_local_state() { 3969 let sandbox = RadrootsCliSandbox::new(); 3970 3971 let workspace = sandbox.json_success(&["--format", "json", "--dry-run", "workspace", "init"]); 3972 let workspace_db = workspace["result"]["local"]["path"] 3973 .as_str() 3974 .expect("workspace db path"); 3975 assert_eq!(workspace["operation_id"], "workspace.init"); 3976 assert_eq!(workspace["dry_run"], true); 3977 assert_eq!(workspace["result"]["state"], "dry_run"); 3978 assert_eq!(workspace["result"]["local"]["replica_db"], "missing"); 3979 assert!(!Path::new(workspace_db).exists()); 3980 3981 let store = sandbox.json_success(&["--format", "json", "--dry-run", "store", "init"]); 3982 let store_db = store["result"]["path"].as_str().expect("store db path"); 3983 assert_eq!(store["operation_id"], "store.init"); 3984 assert_eq!(store["dry_run"], true); 3985 assert_eq!(store["result"]["state"], "dry_run"); 3986 assert_eq!(store["result"]["replica_db"], "missing"); 3987 assert!(!Path::new(store_db).exists()); 3988 3989 let account_create = 3990 sandbox.json_success(&["--format", "json", "--dry-run", "account", "create"]); 3991 assert_eq!(account_create["operation_id"], "account.create"); 3992 assert_eq!(account_create["dry_run"], true); 3993 assert_eq!(account_create["result"]["state"], "dry_run"); 3994 assert_eq!(account_create["result"]["secret_backend"]["state"], "ready"); 3995 3996 let account_list = sandbox.json_success(&["--format", "json", "account", "list"]); 3997 assert_eq!(account_list["result"]["count"], 0); 3998 3999 let created = sandbox.json_success(&["--format", "json", "account", "create"]); 4000 let account_id = created["result"]["account"]["id"] 4001 .as_str() 4002 .expect("account id"); 4003 let clear = sandbox.json_success(&[ 4004 "--format", 4005 "json", 4006 "--dry-run", 4007 "account", 4008 "selection", 4009 "clear", 4010 ]); 4011 assert_eq!(clear["operation_id"], "account.selection.clear"); 4012 assert_eq!(clear["result"]["state"], "dry_run"); 4013 assert_eq!(clear["result"]["cleared_account"]["id"], account_id); 4014 assert_eq!(clear["result"]["remaining_account_count"], 1); 4015 4016 let selection = sandbox.json_success(&["--format", "json", "account", "selection", "get"]); 4017 assert_eq!( 4018 selection["result"]["account_resolution"]["default_account"]["id"], 4019 account_id 4020 ); 4021 } 4022 4023 #[test] 4024 fn seller_dry_runs_preflight_without_mutating_farm_or_listing_files() { 4025 let sandbox = RadrootsCliSandbox::new(); 4026 sandbox.json_success(&["--format", "json", "account", "create"]); 4027 4028 let farm_dry_run = sandbox.json_success(&[ 4029 "--format", 4030 "json", 4031 "--dry-run", 4032 "farm", 4033 "create", 4034 "--name", 4035 "Green Farm", 4036 "--location", 4037 "farmstand", 4038 "--country", 4039 "US", 4040 "--delivery-method", 4041 "pickup", 4042 ]); 4043 let farm_path = farm_dry_run["result"]["config"]["path"] 4044 .as_str() 4045 .expect("farm path"); 4046 assert_eq!(farm_dry_run["operation_id"], "farm.create"); 4047 assert_eq!(farm_dry_run["result"]["state"], "dry_run"); 4048 assert!(!Path::new(farm_path).exists()); 4049 4050 let missing_update = sandbox.json_success(&[ 4051 "--format", 4052 "json", 4053 "--dry-run", 4054 "farm", 4055 "profile", 4056 "update", 4057 "--value", 4058 "Dry Name", 4059 ]); 4060 assert_eq!(missing_update["operation_id"], "farm.profile.update"); 4061 assert_eq!(missing_update["result"]["state"], "unconfigured"); 4062 assert!(!Path::new(farm_path).exists()); 4063 4064 let farm = sandbox.json_success(&[ 4065 "--format", 4066 "json", 4067 "farm", 4068 "create", 4069 "--name", 4070 "Green Farm", 4071 "--location", 4072 "farmstand", 4073 "--country", 4074 "US", 4075 "--delivery-method", 4076 "pickup", 4077 ]); 4078 let farm_path = farm["result"]["config"]["path"] 4079 .as_str() 4080 .expect("farm path"); 4081 let farm_before = fs::read_to_string(farm_path).expect("farm before"); 4082 let farm_update = sandbox.json_success(&[ 4083 "--format", 4084 "json", 4085 "--dry-run", 4086 "farm", 4087 "profile", 4088 "update", 4089 "--value", 4090 "Dry Name", 4091 ]); 4092 assert_eq!(farm_update["operation_id"], "farm.profile.update"); 4093 assert_eq!(farm_update["result"]["state"], "dry_run"); 4094 assert_eq!(farm_update["result"]["config"]["name"], "Dry Name"); 4095 assert_eq!( 4096 fs::read_to_string(farm_path).expect("farm after dry-run"), 4097 farm_before 4098 ); 4099 4100 let listing_path = sandbox.root().join("dry-listing.toml"); 4101 let listing_path_arg = listing_path.to_string_lossy(); 4102 let listing_dry_run = sandbox.json_success(&[ 4103 "--format", 4104 "json", 4105 "--dry-run", 4106 "listing", 4107 "create", 4108 "--output", 4109 listing_path_arg.as_ref(), 4110 "--key", 4111 "eggs", 4112 "--title", 4113 "Eggs", 4114 "--category", 4115 "eggs", 4116 "--summary", 4117 "Fresh eggs", 4118 "--bin-id", 4119 "bin-1", 4120 "--quantity-amount", 4121 "1", 4122 "--quantity-unit", 4123 "each", 4124 "--price-amount", 4125 "6", 4126 "--price-currency", 4127 "USD", 4128 "--price-per-amount", 4129 "1", 4130 "--price-per-unit", 4131 "each", 4132 "--available", 4133 "10", 4134 ]); 4135 assert_eq!(listing_dry_run["operation_id"], "listing.create"); 4136 assert_eq!(listing_dry_run["result"]["state"], "dry_run"); 4137 assert_eq!(listing_dry_run["result"]["file"], listing_path_arg.as_ref()); 4138 assert!(!listing_path.exists()); 4139 4140 fs::write(&listing_path, "existing").expect("existing listing path"); 4141 let (collision_output, collision) = sandbox.json_output(&[ 4142 "--format", 4143 "json", 4144 "--dry-run", 4145 "listing", 4146 "create", 4147 "--output", 4148 listing_path_arg.as_ref(), 4149 "--key", 4150 "eggs", 4151 ]); 4152 assert!(!collision_output.status.success()); 4153 assert_eq!(collision["operation_id"], "listing.create"); 4154 assert_eq!(collision["errors"][0]["code"], "validation_failed"); 4155 4156 let listing_file = create_listing_draft(&sandbox, "seller-dry-run"); 4157 make_listing_publishable( 4158 &listing_file, 4159 farm["result"]["config"]["farm_d_tag"] 4160 .as_str() 4161 .expect("farm d tag"), 4162 ); 4163 let listing_before = fs::read_to_string(&listing_file).expect("listing before"); 4164 let listing_update = sandbox.json_success(&[ 4165 "--format", 4166 "json", 4167 "--dry-run", 4168 "listing", 4169 "update", 4170 listing_file.to_string_lossy().as_ref(), 4171 ]); 4172 assert_eq!(listing_update["operation_id"], "listing.update"); 4173 assert_eq!(listing_update["result"]["state"], "dry_run"); 4174 assert_eq!( 4175 fs::read_to_string(&listing_file).expect("listing after dry-run"), 4176 listing_before 4177 ); 4178 } 4179 4180 #[test] 4181 fn seller_dry_runs_do_not_write_shared_local_work_records() { 4182 let sandbox = RadrootsCliSandbox::new(); 4183 sandbox.json_success(&["--format", "json", "account", "create"]); 4184 4185 sandbox.json_success(&[ 4186 "--format", 4187 "json", 4188 "--dry-run", 4189 "farm", 4190 "create", 4191 "--name", 4192 "Dry Run Farm", 4193 "--location", 4194 "farmstand", 4195 "--country", 4196 "US", 4197 "--delivery-method", 4198 "pickup", 4199 ]); 4200 assert!(sandbox.local_event_records().is_empty()); 4201 4202 let listing_path = sandbox.root().join("dry-run-local-work.toml"); 4203 let listing_path_arg = listing_path.to_string_lossy(); 4204 sandbox.json_success(&[ 4205 "--format", 4206 "json", 4207 "--dry-run", 4208 "listing", 4209 "create", 4210 "--output", 4211 listing_path_arg.as_ref(), 4212 "--key", 4213 "dry-run-eggs", 4214 "--title", 4215 "Eggs", 4216 "--category", 4217 "eggs", 4218 "--summary", 4219 "Fresh eggs", 4220 "--bin-id", 4221 "bin-1", 4222 "--quantity-amount", 4223 "1", 4224 "--quantity-unit", 4225 "each", 4226 "--price-amount", 4227 "6", 4228 "--price-currency", 4229 "USD", 4230 "--price-per-amount", 4231 "1", 4232 "--price-per-unit", 4233 "each", 4234 "--available", 4235 "10", 4236 ]); 4237 assert!(sandbox.local_event_records().is_empty()); 4238 } 4239 4240 #[test] 4241 fn seller_local_writes_append_shared_local_work_records() { 4242 let sandbox = RadrootsCliSandbox::new(); 4243 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 4244 let account_id = account["result"]["account"]["id"] 4245 .as_str() 4246 .expect("account id"); 4247 let farm = sandbox.json_success(&[ 4248 "--format", 4249 "json", 4250 "farm", 4251 "create", 4252 "--name", 4253 "Green Farm", 4254 "--location", 4255 "farmstand", 4256 "--country", 4257 "US", 4258 "--delivery-method", 4259 "pickup", 4260 ]); 4261 let farm_config = &farm["result"]["config"]; 4262 let farm_d_tag = farm_config["farm_d_tag"].as_str().expect("farm d tag"); 4263 let seller_pubkey = farm_config["seller_pubkey"] 4264 .as_str() 4265 .expect("seller pubkey"); 4266 let listing_file = create_listing_draft(&sandbox, "shared-local-eggs"); 4267 4268 let records = sandbox.local_event_records(); 4269 assert_eq!(records.len(), 2); 4270 4271 let farm_record = records 4272 .iter() 4273 .find(|record| { 4274 record 4275 .local_work_json 4276 .as_ref() 4277 .and_then(|payload| payload["record_kind"].as_str()) 4278 == Some("farm_config_v1") 4279 }) 4280 .expect("farm local work record"); 4281 assert_eq!(farm_record.family, LocalRecordFamily::LocalWork); 4282 assert_eq!(farm_record.status, LocalRecordStatus::LocalSaved); 4283 assert_eq!(farm_record.source_runtime, SourceRuntime::Cli); 4284 assert_eq!(farm_record.outbox_status, PublishOutboxStatus::None); 4285 assert_eq!(farm_record.owner_account_id.as_deref(), Some(account_id)); 4286 assert_eq!(farm_record.owner_pubkey.as_deref(), Some(seller_pubkey)); 4287 assert_eq!(farm_record.farm_id.as_deref(), Some(farm_d_tag)); 4288 assert_eq!(farm_record.listing_addr, None); 4289 let farm_payload = farm_record 4290 .local_work_json 4291 .as_ref() 4292 .expect("farm local work payload"); 4293 assert_eq!(farm_payload["scope"], "workspace"); 4294 assert_eq!(farm_payload["document"]["farm"]["d_tag"], farm_d_tag); 4295 4296 let listing_record = records 4297 .iter() 4298 .find(|record| { 4299 record 4300 .local_work_json 4301 .as_ref() 4302 .and_then(|payload| payload["record_kind"].as_str()) 4303 == Some("listing_draft_v1") 4304 }) 4305 .expect("listing local work record"); 4306 assert_eq!(listing_record.family, LocalRecordFamily::LocalWork); 4307 assert_eq!(listing_record.status, LocalRecordStatus::LocalSaved); 4308 assert_eq!(listing_record.source_runtime, SourceRuntime::Cli); 4309 assert_eq!(listing_record.outbox_status, PublishOutboxStatus::None); 4310 assert_eq!(listing_record.owner_account_id.as_deref(), Some(account_id)); 4311 assert_eq!(listing_record.owner_pubkey.as_deref(), Some(seller_pubkey)); 4312 assert_eq!(listing_record.farm_id.as_deref(), Some(farm_d_tag)); 4313 assert!( 4314 listing_record 4315 .listing_addr 4316 .as_deref() 4317 .expect("listing addr") 4318 .starts_with(format!("30402:{seller_pubkey}:").as_str()) 4319 ); 4320 let listing_payload = listing_record 4321 .local_work_json 4322 .as_ref() 4323 .expect("listing local work payload"); 4324 assert_eq!(listing_payload["path"], listing_file.display().to_string()); 4325 assert_eq!( 4326 listing_payload["document"]["product"]["key"], 4327 "shared-local-eggs" 4328 ); 4329 4330 let farm_update = sandbox.json_success(&[ 4331 "--format", 4332 "json", 4333 "farm", 4334 "profile", 4335 "update", 4336 "--value", 4337 "Green Farm Updated", 4338 ]); 4339 assert_eq!(farm_update["operation_id"], "farm.profile.update"); 4340 assert_eq!(farm_update["result"]["state"], "updated"); 4341 let second = sandbox.json_success(&["--format", "json", "account", "create"]); 4342 let second_account_id = second["result"]["account"]["id"] 4343 .as_str() 4344 .expect("second account id"); 4345 sandbox.json_success(&[ 4346 "--format", 4347 "json", 4348 "--approval-token", 4349 "approve", 4350 "listing", 4351 "rebind", 4352 listing_file.to_string_lossy().as_ref(), 4353 second_account_id, 4354 "--farm-d-tag", 4355 farm_d_tag, 4356 ]); 4357 4358 let updated_records = sandbox.local_event_records(); 4359 assert_eq!(updated_records.len(), 4); 4360 let latest_farm_payload = updated_records 4361 .iter() 4362 .filter(|record| { 4363 record 4364 .local_work_json 4365 .as_ref() 4366 .and_then(|payload| payload["record_kind"].as_str()) 4367 == Some("farm_config_v1") 4368 }) 4369 .max_by_key(|record| record.seq) 4370 .and_then(|record| record.local_work_json.as_ref()) 4371 .expect("latest farm payload"); 4372 assert_eq!( 4373 latest_farm_payload["document"]["profile"]["name"], 4374 "Green Farm Updated" 4375 ); 4376 let latest_listing = updated_records 4377 .iter() 4378 .filter(|record| { 4379 record 4380 .local_work_json 4381 .as_ref() 4382 .and_then(|payload| payload["record_kind"].as_str()) 4383 == Some("listing_draft_v1") 4384 }) 4385 .max_by_key(|record| record.seq) 4386 .expect("latest listing record"); 4387 assert_eq!( 4388 latest_listing.owner_account_id.as_deref(), 4389 Some(second_account_id) 4390 ); 4391 let latest_listing_payload = latest_listing 4392 .local_work_json 4393 .as_ref() 4394 .expect("latest listing payload"); 4395 assert_eq!( 4396 latest_listing_payload["document"]["seller_actor"]["account_id"], 4397 second_account_id 4398 ); 4399 } 4400 4401 #[test] 4402 fn listing_app_records_list_and_export_to_valid_cli_draft() { 4403 let sandbox = RadrootsCliSandbox::new(); 4404 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 4405 let account_id = account["result"]["account"]["id"] 4406 .as_str() 4407 .expect("account id"); 4408 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 4409 let seller_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 4410 .as_str() 4411 .expect("seller pubkey"); 4412 let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; 4413 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4414 seed_app_farm_record(&sandbox, account_id, seller_pubkey, farm_d_tag); 4415 let listing_record_id = seed_app_listing_record( 4416 &sandbox, 4417 account_id, 4418 seller_pubkey, 4419 farm_d_tag, 4420 listing_d_tag, 4421 ); 4422 4423 let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); 4424 assert_eq!(list["operation_id"], "listing.app.list"); 4425 assert_eq!(list["result"]["state"], "ready"); 4426 assert_eq!(list["result"]["count"], 2); 4427 let listing_row = list["result"]["records"] 4428 .as_array() 4429 .expect("records") 4430 .iter() 4431 .find(|record| record["record_id"] == listing_record_id) 4432 .expect("listing row"); 4433 assert_eq!(listing_row["record_kind"], "listing_draft_v1"); 4434 assert_eq!(listing_row["source_runtime"], "app"); 4435 assert_eq!(listing_row["exportable"], true); 4436 assert_eq!(listing_row["listing_id"], listing_d_tag); 4437 assert_eq!(listing_row["title"], "App Eggs"); 4438 4439 let export_path = sandbox.root().join("app-eggs.toml"); 4440 let export_path_arg = export_path.to_string_lossy(); 4441 let dry_run = sandbox.json_success(&[ 4442 "--format", 4443 "json", 4444 "--dry-run", 4445 "listing", 4446 "app", 4447 "export", 4448 listing_record_id.as_str(), 4449 "--output", 4450 export_path_arg.as_ref(), 4451 ]); 4452 assert_eq!(dry_run["operation_id"], "listing.app.export"); 4453 assert_eq!(dry_run["result"]["state"], "dry_run"); 4454 assert_eq!(dry_run["result"]["valid"], true); 4455 assert!(!export_path.exists()); 4456 4457 let export = sandbox.json_success(&[ 4458 "--format", 4459 "json", 4460 "listing", 4461 "app", 4462 "export", 4463 listing_record_id.as_str(), 4464 "--output", 4465 export_path_arg.as_ref(), 4466 ]); 4467 assert_eq!(export["operation_id"], "listing.app.export"); 4468 assert_eq!(export["result"]["state"], "exported"); 4469 assert_eq!(export["result"]["listing_id"], listing_d_tag); 4470 assert_eq!(export["result"]["seller_account_id"], account_id); 4471 assert!(export_path.exists()); 4472 let exported_contents = fs::read_to_string(&export_path).expect("exported listing draft"); 4473 assert!(exported_contents.contains("quantity_unit = \"each\"")); 4474 assert!(exported_contents.contains("price_per_unit = \"each\"")); 4475 assert!(exported_contents.contains("label = \"dozen\"")); 4476 4477 let validate = sandbox.json_success(&[ 4478 "--format", 4479 "json", 4480 "listing", 4481 "validate", 4482 export_path_arg.as_ref(), 4483 ]); 4484 assert_eq!(validate["operation_id"], "listing.validate"); 4485 assert_eq!(validate["result"]["valid"], true); 4486 assert_eq!(validate["result"]["listing_id"], listing_d_tag); 4487 assert_eq!(validate["result"]["seller_account_id"], account_id); 4488 } 4489 4490 #[test] 4491 fn listing_app_records_list_current_records_and_blocks_stale_export() { 4492 let sandbox = RadrootsCliSandbox::new(); 4493 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 4494 let account_id = account["result"]["account"]["id"] 4495 .as_str() 4496 .expect("account id"); 4497 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 4498 let seller_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 4499 .as_str() 4500 .expect("seller pubkey"); 4501 let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; 4502 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4503 seed_app_farm_record(&sandbox, account_id, seller_pubkey, farm_d_tag); 4504 let stale_record_id = seed_app_listing_record_variant( 4505 &sandbox, 4506 account_id, 4507 Some(seller_pubkey), 4508 farm_d_tag, 4509 listing_d_tag, 4510 "stale", 4511 "Old App Eggs", 4512 None, 4513 ); 4514 let current_record_id = seed_app_listing_record_variant( 4515 &sandbox, 4516 account_id, 4517 Some(seller_pubkey), 4518 farm_d_tag, 4519 listing_d_tag, 4520 "current", 4521 "Current App Eggs", 4522 None, 4523 ); 4524 4525 let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); 4526 assert_eq!(list["result"]["count"], 2); 4527 assert_eq!(list["result"]["limit"], 500); 4528 assert_eq!(list["result"]["has_more"], false); 4529 let records = list["result"]["records"].as_array().expect("records"); 4530 assert!( 4531 records 4532 .iter() 4533 .all(|record| record["record_id"] != stale_record_id) 4534 ); 4535 let listing_row = records 4536 .iter() 4537 .find(|record| record["record_id"] == current_record_id) 4538 .expect("current listing row"); 4539 assert_eq!(listing_row["title"], "Current App Eggs"); 4540 assert_eq!(listing_row["superseded_count"], 1); 4541 assert_eq!(listing_row["exportable"], true); 4542 assert!( 4543 listing_row["change_seq"] 4544 .as_i64() 4545 .expect("listing change seq") 4546 > records[1]["change_seq"].as_i64().expect("farm change seq") 4547 ); 4548 4549 let export_path = sandbox.root().join("stale-app-eggs.toml"); 4550 let export_path_arg = export_path.to_string_lossy(); 4551 let (output, stale_export) = sandbox.json_output(&[ 4552 "--format", 4553 "json", 4554 "listing", 4555 "app", 4556 "export", 4557 stale_record_id.as_str(), 4558 "--output", 4559 export_path_arg.as_ref(), 4560 ]); 4561 assert!(!output.status.success()); 4562 assert_eq!(stale_export["result"], Value::Null); 4563 assert_eq!(stale_export["errors"][0]["detail"]["state"], "stale"); 4564 assert_eq!(stale_export["errors"][0]["detail"]["valid"], false); 4565 assert!( 4566 stale_export["errors"][0]["message"] 4567 .as_str() 4568 .expect("stale reason") 4569 .contains(current_record_id.as_str()) 4570 ); 4571 assert!(!export_path.exists()); 4572 } 4573 4574 #[test] 4575 fn listing_app_records_list_includes_new_records_after_older_volume() { 4576 let sandbox = RadrootsCliSandbox::new(); 4577 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 4578 let account_id = account["result"]["account"]["id"] 4579 .as_str() 4580 .expect("account id"); 4581 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 4582 let seller_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 4583 .as_str() 4584 .expect("seller pubkey"); 4585 for index in 0..505 { 4586 let farm_d_tag = format!("F{index:021}"); 4587 seed_app_farm_record(&sandbox, account_id, seller_pubkey, farm_d_tag.as_str()); 4588 } 4589 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4590 let current_record_id = seed_app_listing_record_variant( 4591 &sandbox, 4592 account_id, 4593 Some(seller_pubkey), 4594 "AAAAAAAAAAAAAAAAAAAAAw", 4595 listing_d_tag, 4596 "current", 4597 "Newest App Eggs", 4598 None, 4599 ); 4600 4601 let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); 4602 assert_eq!(list["result"]["limit"], 500); 4603 assert_eq!(list["result"]["count"], 500); 4604 assert_eq!(list["result"]["has_more"], true); 4605 assert!(list["result"]["next_before_change_seq"].as_i64().is_some()); 4606 assert!(list["result"]["next_before_seq"].as_i64().is_some()); 4607 let records = list["result"]["records"].as_array().expect("records"); 4608 assert_eq!(records[0]["record_id"], current_record_id); 4609 assert!( 4610 records 4611 .iter() 4612 .any(|record| record["record_id"] == current_record_id) 4613 ); 4614 } 4615 4616 #[test] 4617 fn listing_app_records_keep_same_listing_id_separate_by_owner_pubkey() { 4618 let sandbox = RadrootsCliSandbox::new(); 4619 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 4620 let account_id = account["result"]["account"]["id"] 4621 .as_str() 4622 .expect("account id"); 4623 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 4624 let seller_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 4625 .as_str() 4626 .expect("seller pubkey"); 4627 let other_pubkey = identity_public(83).public_key_hex; 4628 let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; 4629 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4630 let first_record_id = seed_app_listing_record_variant_without_listing_addr( 4631 &sandbox, 4632 account_id, 4633 Some(seller_pubkey), 4634 farm_d_tag, 4635 listing_d_tag, 4636 "owner-one", 4637 "First Owner Eggs", 4638 ); 4639 let second_record_id = seed_app_listing_record_variant_without_listing_addr( 4640 &sandbox, 4641 "acct_owner_two", 4642 Some(other_pubkey.as_str()), 4643 farm_d_tag, 4644 listing_d_tag, 4645 "owner-two", 4646 "Second Owner Eggs", 4647 ); 4648 4649 let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); 4650 assert_eq!(list["result"]["count"], 2); 4651 let records = list["result"]["records"].as_array().expect("records"); 4652 let first_row = records 4653 .iter() 4654 .find(|record| record["record_id"] == first_record_id) 4655 .expect("first owner row"); 4656 let second_row = records 4657 .iter() 4658 .find(|record| record["record_id"] == second_record_id) 4659 .expect("second owner row"); 4660 assert_eq!(first_row["title"], "First Owner Eggs"); 4661 assert_eq!(second_row["title"], "Second Owner Eggs"); 4662 assert_eq!(first_row["superseded_count"], 0); 4663 assert_eq!(second_row["superseded_count"], 0); 4664 assert_eq!(first_row["exportable"], true); 4665 assert_eq!(second_row["exportable"], true); 4666 } 4667 4668 #[test] 4669 fn listing_app_records_export_blocks_stale_when_current_is_beyond_first_page() { 4670 let sandbox = RadrootsCliSandbox::new(); 4671 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 4672 let account_id = account["result"]["account"]["id"] 4673 .as_str() 4674 .expect("account id"); 4675 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 4676 let seller_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 4677 .as_str() 4678 .expect("seller pubkey"); 4679 let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; 4680 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4681 let stale_record_id = seed_app_listing_record_variant( 4682 &sandbox, 4683 account_id, 4684 Some(seller_pubkey), 4685 farm_d_tag, 4686 listing_d_tag, 4687 "paged-stale", 4688 "Paged Old Eggs", 4689 None, 4690 ); 4691 let current_record_id = seed_app_listing_record_variant( 4692 &sandbox, 4693 account_id, 4694 Some(seller_pubkey), 4695 farm_d_tag, 4696 listing_d_tag, 4697 "paged-current", 4698 "Paged Current Eggs", 4699 None, 4700 ); 4701 for index in 0..505 { 4702 let farm_d_tag = format!("G{index:021}"); 4703 seed_app_farm_record(&sandbox, account_id, seller_pubkey, farm_d_tag.as_str()); 4704 } 4705 4706 let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); 4707 assert_eq!(list["result"]["count"], 500); 4708 assert_eq!(list["result"]["has_more"], true); 4709 let records = list["result"]["records"].as_array().expect("records"); 4710 assert!( 4711 records 4712 .iter() 4713 .all(|record| record["record_id"] != current_record_id) 4714 ); 4715 4716 let export_path = sandbox.root().join("paged-stale-app-eggs.toml"); 4717 let export_path_arg = export_path.to_string_lossy(); 4718 let (output, stale_export) = sandbox.json_output(&[ 4719 "--format", 4720 "json", 4721 "listing", 4722 "app", 4723 "export", 4724 stale_record_id.as_str(), 4725 "--output", 4726 export_path_arg.as_ref(), 4727 ]); 4728 assert!(!output.status.success()); 4729 assert_eq!(stale_export["result"], Value::Null); 4730 assert_eq!(stale_export["errors"][0]["detail"]["state"], "stale"); 4731 assert!( 4732 stale_export["errors"][0]["message"] 4733 .as_str() 4734 .expect("stale reason") 4735 .contains(current_record_id.as_str()) 4736 ); 4737 assert!(!export_path.exists()); 4738 } 4739 4740 #[test] 4741 fn listing_app_records_mark_unresolved_pubkey_records_non_exportable() { 4742 let sandbox = RadrootsCliSandbox::new(); 4743 let account_id = "acct_unresolved"; 4744 let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; 4745 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4746 let record_id = seed_app_listing_record_variant( 4747 &sandbox, 4748 account_id, 4749 None, 4750 farm_d_tag, 4751 listing_d_tag, 4752 "unresolved", 4753 "Unresolved App Eggs", 4754 Some(json!({ 4755 "state": "identity_unresolved", 4756 "reason": "canonical_hex_pubkey_required" 4757 })), 4758 ); 4759 4760 let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); 4761 assert_eq!(list["result"]["count"], 1); 4762 let listing_row = &list["result"]["records"][0]; 4763 assert_eq!(listing_row["record_id"], record_id); 4764 assert_eq!(listing_row["title"], "Unresolved App Eggs"); 4765 assert_eq!(listing_row["exportable"], false); 4766 assert_eq!( 4767 listing_row["reason"], 4768 "canonical hex pubkey required before export" 4769 ); 4770 assert!(listing_row.get("listing_addr").is_none()); 4771 4772 let export_path = sandbox.root().join("unresolved-app-eggs.toml"); 4773 let export_path_arg = export_path.to_string_lossy(); 4774 let (output, export) = sandbox.json_output(&[ 4775 "--format", 4776 "json", 4777 "listing", 4778 "app", 4779 "export", 4780 record_id.as_str(), 4781 "--output", 4782 export_path_arg.as_ref(), 4783 ]); 4784 assert!(!output.status.success()); 4785 assert_eq!(export["result"], Value::Null); 4786 assert_eq!(export["errors"][0]["detail"]["state"], "unsupported"); 4787 assert_eq!( 4788 export["errors"][0]["message"], 4789 "canonical hex pubkey required before export" 4790 ); 4791 assert!(!export_path.exists()); 4792 } 4793 4794 #[test] 4795 fn listing_app_records_ignore_body_pubkey_without_owner_metadata() { 4796 let sandbox = RadrootsCliSandbox::new(); 4797 let account_id = "acct_body_only"; 4798 let body_pubkey = identity_public(91).public_key_hex; 4799 let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; 4800 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4801 let record_id = seed_app_listing_record_identity_variant( 4802 &sandbox, 4803 account_id, 4804 Some(body_pubkey.as_str()), 4805 None, 4806 farm_d_tag, 4807 listing_d_tag, 4808 "body-only", 4809 "Body Only App Eggs", 4810 Some(json!({ "state": "exportable" })), 4811 false, 4812 ); 4813 4814 let list = sandbox.json_success(&["--format", "json", "listing", "app", "list"]); 4815 assert_eq!(list["result"]["count"], 1); 4816 let listing_row = &list["result"]["records"][0]; 4817 assert_eq!(listing_row["record_id"], record_id); 4818 assert_eq!(listing_row["title"], "Body Only App Eggs"); 4819 assert_eq!(listing_row["exportable"], false); 4820 assert_eq!( 4821 listing_row["reason"], 4822 "canonical hex pubkey required before export" 4823 ); 4824 assert!( 4825 listing_row 4826 .get("actions") 4827 .and_then(Value::as_array) 4828 .is_none_or(Vec::is_empty) 4829 ); 4830 4831 let export_path = sandbox.root().join("body-only-app-eggs.toml"); 4832 let export_path_arg = export_path.to_string_lossy(); 4833 let (output, export) = sandbox.json_output(&[ 4834 "--format", 4835 "json", 4836 "listing", 4837 "app", 4838 "export", 4839 record_id.as_str(), 4840 "--output", 4841 export_path_arg.as_ref(), 4842 ]); 4843 assert!(!output.status.success()); 4844 assert_eq!(export["result"], Value::Null); 4845 assert_eq!(export["errors"][0]["detail"]["state"], "unsupported"); 4846 assert_eq!( 4847 export["errors"][0]["message"], 4848 "canonical hex pubkey required before export" 4849 ); 4850 assert!(!export_path.exists()); 4851 } 4852 4853 #[test] 4854 fn listing_app_records_export_uses_record_owner_over_body_pubkey() { 4855 let sandbox = RadrootsCliSandbox::new(); 4856 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 4857 let account_id = account["result"]["account"]["id"] 4858 .as_str() 4859 .expect("account id"); 4860 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 4861 let owner_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 4862 .as_str() 4863 .expect("seller pubkey"); 4864 let body_pubkey = identity_public(92).public_key_hex; 4865 let farm_d_tag = "AAAAAAAAAAAAAAAAAAAAAw"; 4866 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4867 let record_id = seed_app_listing_record_identity_variant( 4868 &sandbox, 4869 account_id, 4870 Some(body_pubkey.as_str()), 4871 Some(owner_pubkey), 4872 farm_d_tag, 4873 listing_d_tag, 4874 "owner-wins", 4875 "Owner Wins App Eggs", 4876 None, 4877 true, 4878 ); 4879 4880 let export_path = sandbox.root().join("owner-wins-app-eggs.toml"); 4881 let export_path_arg = export_path.to_string_lossy(); 4882 let export = sandbox.json_success(&[ 4883 "--format", 4884 "json", 4885 "listing", 4886 "app", 4887 "export", 4888 record_id.as_str(), 4889 "--output", 4890 export_path_arg.as_ref(), 4891 ]); 4892 assert_eq!(export["operation_id"], "listing.app.export"); 4893 assert_eq!(export["result"]["state"], "exported"); 4894 assert_eq!(export["result"]["seller_pubkey"], owner_pubkey); 4895 let exported_contents = fs::read_to_string(&export_path).expect("exported listing draft"); 4896 assert!(exported_contents.contains(format!("pubkey = \"{owner_pubkey}\"").as_str())); 4897 assert!(!exported_contents.contains(body_pubkey.as_str())); 4898 } 4899 4900 #[test] 4901 fn order_app_records_list_export_get_and_submit_supported_app_order() { 4902 let sandbox = RadrootsCliSandbox::new(); 4903 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 4904 let account_id = account["result"]["account"]["id"] 4905 .as_str() 4906 .expect("account id"); 4907 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 4908 let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 4909 .as_str() 4910 .expect("buyer pubkey"); 4911 let seller_pubkey = identity_public(73).public_key_hex; 4912 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 4913 let listing_addr = format!("30402:{seller_pubkey}:{listing_d_tag}"); 4914 let listing_event_id = seed_orderable_listing(&sandbox, listing_addr.as_str()); 4915 let order_id = "018f47a8-7b2c-7000-8000-000000000011"; 4916 let record_id = seed_app_order_record( 4917 &sandbox, 4918 account_id, 4919 buyer_pubkey, 4920 seller_pubkey.as_str(), 4921 order_id, 4922 listing_addr.as_str(), 4923 listing_event_id.as_str(), 4924 ); 4925 4926 let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); 4927 assert_eq!(app_list["operation_id"], "order.app.list"); 4928 assert_eq!(app_list["result"]["state"], "ready"); 4929 assert_eq!(app_list["result"]["count"], 1); 4930 assert_eq!(app_list["result"]["records"][0]["record_id"], record_id); 4931 assert_eq!(app_list["result"]["records"][0]["order_id"], order_id); 4932 assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], true); 4933 assert_eq!(app_list["result"]["records"][0]["exportable"], true); 4934 assert_no_removed_command_reference(&app_list, &["order", "app", "list"]); 4935 assert_no_daemon_runtime_reference(&app_list, &["order", "app", "list"]); 4936 4937 let orders = sandbox.json_success(&["--format", "json", "order", "list"]); 4938 assert_eq!(orders["operation_id"], "order.list"); 4939 assert_eq!(orders["result"]["state"], "ready"); 4940 assert_eq!(orders["result"]["count"], 1); 4941 assert_eq!(orders["result"]["orders"][0]["id"], order_id); 4942 assert_eq!(orders["result"]["orders"][0]["ready_for_submit"], true); 4943 assert_eq!( 4944 orders["result"]["orders"][0]["listing_event_id"], 4945 listing_event_id 4946 ); 4947 assert_eq!( 4948 orders["result"]["orders"][0]["buyer_account_id"], 4949 account_id 4950 ); 4951 assert_eq!( 4952 orders["result"]["orders"][0]["file"], 4953 format!("shared-local-events/{record_id}") 4954 ); 4955 assert_no_removed_command_reference(&orders, &["order", "list"]); 4956 assert_no_daemon_runtime_reference(&orders, &["order", "list"]); 4957 4958 let get_by_record = 4959 sandbox.json_success(&["--format", "json", "order", "get", record_id.as_str()]); 4960 assert_eq!(get_by_record["operation_id"], "order.get"); 4961 assert_eq!(get_by_record["result"]["state"], "ready"); 4962 assert_eq!(get_by_record["result"]["order_id"], order_id); 4963 assert_eq!(get_by_record["result"]["ready_for_submit"], true); 4964 4965 let export_path = sandbox.root().join("app-order.toml"); 4966 let export_path_arg = export_path.to_string_lossy(); 4967 let dry_run = sandbox.json_success(&[ 4968 "--format", 4969 "json", 4970 "--dry-run", 4971 "order", 4972 "app", 4973 "export", 4974 record_id.as_str(), 4975 "--output", 4976 export_path_arg.as_ref(), 4977 ]); 4978 assert_eq!(dry_run["operation_id"], "order.app.export"); 4979 assert_eq!(dry_run["result"]["state"], "dry_run"); 4980 assert_eq!(dry_run["result"]["valid"], true); 4981 assert!(!export_path.exists()); 4982 4983 let export = sandbox.json_success(&[ 4984 "--format", 4985 "json", 4986 "order", 4987 "app", 4988 "export", 4989 record_id.as_str(), 4990 "--output", 4991 export_path_arg.as_ref(), 4992 ]); 4993 assert_eq!(export["operation_id"], "order.app.export"); 4994 assert_eq!(export["result"]["state"], "exported"); 4995 assert_eq!(export["result"]["order_id"], order_id); 4996 assert!(export_path.exists()); 4997 let exported_contents = fs::read_to_string(&export_path).expect("exported order draft"); 4998 assert!(exported_contents.contains("kind = \"order_draft_v1\"")); 4999 assert!(exported_contents.contains(format!("order_id = \"{order_id}\"").as_str())); 5000 assert!(exported_contents.contains("source = \"resolved_account\"")); 5001 5002 let submit = sandbox.json_success(&[ 5003 "--format", 5004 "json", 5005 "--dry-run", 5006 "order", 5007 "submit", 5008 record_id.as_str(), 5009 ]); 5010 assert_eq!(submit["operation_id"], "order.submit"); 5011 assert_eq!(submit["result"]["state"], "dry_run"); 5012 assert_eq!(submit["result"]["source"], "SDK order submit ยท local key"); 5013 assert_eq!(submit["result"]["event_kind"], 3422); 5014 assert_eq!( 5015 submit["result"]["target_relays"][0], 5016 ORDERABLE_LISTING_RELAY 5017 ); 5018 assert_eq!( 5019 submit["result"]["event_id"] 5020 .as_str() 5021 .expect("event id") 5022 .len(), 5023 64 5024 ); 5025 } 5026 5027 #[test] 5028 fn order_app_records_treat_matching_signed_evidence_as_submitted() { 5029 let sandbox = RadrootsCliSandbox::new(); 5030 let buyer = identity_secret(97); 5031 let buyer_public_file = 5032 write_public_identity_profile(&sandbox, "app-order-submitted-buyer", &buyer.to_public()); 5033 let imported = sandbox.json_success(&[ 5034 "--format", 5035 "json", 5036 "--approval-token", 5037 "approve", 5038 "account", 5039 "import", 5040 "--default", 5041 buyer_public_file.to_string_lossy().as_ref(), 5042 ]); 5043 let account_id = imported["result"]["account"]["id"] 5044 .as_str() 5045 .expect("account id"); 5046 let buyer_secret_file = write_secret_identity_profile(&sandbox, "app-order-submitted", &buyer); 5047 sandbox.json_success(&[ 5048 "--format", 5049 "json", 5050 "--approval-token", 5051 "approve", 5052 "account", 5053 "attach-secret", 5054 account_id, 5055 buyer_secret_file.to_string_lossy().as_ref(), 5056 "--default", 5057 ]); 5058 5059 let buyer_pubkey = buyer.public_key_hex(); 5060 let seller_pubkey = identity_public(77).public_key_hex; 5061 let listing_d_tag = "AAAAAAAAAAAAAAAAAAAAAQ"; 5062 let listing_addr = format!("30402:{seller_pubkey}:{listing_d_tag}"); 5063 let listing_event_id = seed_orderable_listing(&sandbox, listing_addr.as_str()); 5064 let order_id = "018f47a8-7b2c-7000-8000-000000000016"; 5065 let record_id = seed_app_order_record( 5066 &sandbox, 5067 account_id, 5068 buyer_pubkey.as_str(), 5069 seller_pubkey.as_str(), 5070 order_id, 5071 listing_addr.as_str(), 5072 listing_event_id.as_str(), 5073 ); 5074 let signed_event = signed_app_order_request_event( 5075 &buyer, 5076 order_id, 5077 listing_addr.as_str(), 5078 listing_event_id.as_str(), 5079 seller_pubkey.as_str(), 5080 2, 5081 ); 5082 let signed_event_id = signed_event.id.to_hex(); 5083 append_app_signed_order_request_record( 5084 &sandbox, 5085 account_id, 5086 listing_addr.as_str(), 5087 &signed_event, 5088 ); 5089 5090 let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); 5091 let listed = &app_list["result"]["records"][0]; 5092 assert_eq!(listed["record_id"], record_id); 5093 assert_eq!(listed["status"], "submitted"); 5094 assert_eq!(listed["ready_for_submit"], false); 5095 assert_eq!(listed["exportable"], false); 5096 assert_eq!( 5097 listed["actions"].as_array().expect("actions"), 5098 &vec![json!(format!("radroots order status get {order_id}"))] 5099 ); 5100 5101 let orders = sandbox.json_success(&["--format", "json", "order", "list"]); 5102 assert_eq!(orders["result"]["state"], "ready"); 5103 assert_eq!(orders["result"]["orders"][0]["state"], "submitted"); 5104 assert_eq!(orders["result"]["orders"][0]["ready_for_submit"], false); 5105 5106 let get_by_record = 5107 sandbox.json_success(&["--format", "json", "order", "get", record_id.as_str()]); 5108 assert_eq!(get_by_record["result"]["state"], "submitted"); 5109 assert_eq!(get_by_record["result"]["ready_for_submit"], false); 5110 assert_eq!( 5111 get_by_record["result"]["issues"][0]["code"], 5112 "app_order_already_submitted" 5113 ); 5114 assert_eq!( 5115 get_by_record["result"]["issues"][0]["event_ids"][0], 5116 signed_event_id 5117 ); 5118 assert_eq!( 5119 get_by_record["result"]["actions"] 5120 .as_array() 5121 .expect("actions"), 5122 &vec![json!(format!("radroots order status get {order_id}"))] 5123 ); 5124 5125 let export_path = sandbox.root().join("submitted-app-order.toml"); 5126 let export_path_arg = export_path.to_string_lossy(); 5127 let (export_output, export) = sandbox.json_output(&[ 5128 "--format", 5129 "json", 5130 "order", 5131 "app", 5132 "export", 5133 record_id.as_str(), 5134 "--output", 5135 export_path_arg.as_ref(), 5136 ]); 5137 assert!(!export_output.status.success()); 5138 assert_eq!(export["operation_id"], "order.app.export"); 5139 assert_eq!(export["errors"][0]["detail"]["state"], "already_submitted"); 5140 assert_eq!(export["errors"][0]["detail"]["valid"], false); 5141 assert!(!export_path.exists()); 5142 5143 let submit = sandbox.json_success(&[ 5144 "--format", 5145 "json", 5146 "--dry-run", 5147 "order", 5148 "submit", 5149 record_id.as_str(), 5150 ]); 5151 assert_eq!(submit["operation_id"], "order.submit"); 5152 assert_eq!(submit["result"]["state"], "submitted"); 5153 assert_eq!(submit["result"]["deduplicated"], true); 5154 assert_eq!(submit["result"]["event_id"], signed_event_id); 5155 assert!( 5156 submit["result"] 5157 .get("actions") 5158 .and_then(Value::as_array) 5159 .is_none_or(Vec::is_empty) 5160 ); 5161 } 5162 5163 #[test] 5164 fn order_app_records_fail_closed_when_signed_evidence_conflicts() { 5165 let sandbox = RadrootsCliSandbox::new(); 5166 let buyer = identity_secret(98); 5167 let buyer_public_file = 5168 write_public_identity_profile(&sandbox, "app-order-conflict-buyer", &buyer.to_public()); 5169 let imported = sandbox.json_success(&[ 5170 "--format", 5171 "json", 5172 "--approval-token", 5173 "approve", 5174 "account", 5175 "import", 5176 "--default", 5177 buyer_public_file.to_string_lossy().as_ref(), 5178 ]); 5179 let account_id = imported["result"]["account"]["id"] 5180 .as_str() 5181 .expect("account id"); 5182 let buyer_secret_file = write_secret_identity_profile(&sandbox, "app-order-conflict", &buyer); 5183 sandbox.json_success(&[ 5184 "--format", 5185 "json", 5186 "--approval-token", 5187 "approve", 5188 "account", 5189 "attach-secret", 5190 account_id, 5191 buyer_secret_file.to_string_lossy().as_ref(), 5192 "--default", 5193 ]); 5194 5195 let buyer_pubkey = buyer.public_key_hex(); 5196 let seller_pubkey = identity_public(78).public_key_hex; 5197 let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); 5198 let listing_event_id = seed_orderable_listing(&sandbox, listing_addr.as_str()); 5199 let order_id = "018f47a8-7b2c-7000-8000-000000000017"; 5200 let record_id = seed_app_order_record( 5201 &sandbox, 5202 account_id, 5203 buyer_pubkey.as_str(), 5204 seller_pubkey.as_str(), 5205 order_id, 5206 listing_addr.as_str(), 5207 listing_event_id.as_str(), 5208 ); 5209 let signed_event = signed_app_order_request_event( 5210 &buyer, 5211 order_id, 5212 listing_addr.as_str(), 5213 listing_event_id.as_str(), 5214 seller_pubkey.as_str(), 5215 3, 5216 ); 5217 append_app_signed_order_request_record( 5218 &sandbox, 5219 account_id, 5220 listing_addr.as_str(), 5221 &signed_event, 5222 ); 5223 5224 let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); 5225 let listed = &app_list["result"]["records"][0]; 5226 assert_eq!(listed["record_id"], record_id); 5227 assert_eq!(listed["status"], "conflict"); 5228 assert_eq!(listed["ready_for_submit"], false); 5229 assert_eq!(listed["exportable"], false); 5230 assert!( 5231 listed["reason"] 5232 .as_str() 5233 .expect("conflict reason") 5234 .contains("conflicts") 5235 ); 5236 assert!( 5237 !listed["actions"] 5238 .as_array() 5239 .expect("actions") 5240 .iter() 5241 .any(|action| action 5242 .as_str() 5243 .expect("action") 5244 .contains("order app export")) 5245 ); 5246 5247 let export_path = sandbox.root().join("conflicting-signed-app-order.toml"); 5248 let export_path_arg = export_path.to_string_lossy(); 5249 let (export_output, export) = sandbox.json_output(&[ 5250 "--format", 5251 "json", 5252 "order", 5253 "app", 5254 "export", 5255 record_id.as_str(), 5256 "--output", 5257 export_path_arg.as_ref(), 5258 ]); 5259 assert!(!export_output.status.success()); 5260 assert_eq!(export["operation_id"], "order.app.export"); 5261 assert_eq!(export["errors"][0]["detail"]["state"], "conflict"); 5262 assert_eq!( 5263 export["errors"][0]["detail"]["issues"][0]["code"], 5264 "app_order_signed_evidence_conflict" 5265 ); 5266 assert!(!export_path.exists()); 5267 5268 let (submit_output, submit) = sandbox.json_output(&[ 5269 "--format", 5270 "json", 5271 "--dry-run", 5272 "order", 5273 "submit", 5274 record_id.as_str(), 5275 ]); 5276 assert!(!submit_output.status.success()); 5277 assert_eq!(submit["operation_id"], "order.submit"); 5278 assert_eq!(submit["errors"][0]["detail"]["state"], "invalid"); 5279 assert_eq!( 5280 submit["errors"][0]["detail"]["issues"][0]["code"], 5281 "app_order_signed_evidence_conflict" 5282 ); 5283 } 5284 5285 #[test] 5286 fn order_app_records_fail_closed_when_not_current_or_supported() { 5287 let sandbox = RadrootsCliSandbox::new(); 5288 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 5289 let account_id = account["result"]["account"]["id"] 5290 .as_str() 5291 .expect("account id"); 5292 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 5293 let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 5294 .as_str() 5295 .expect("buyer pubkey"); 5296 let seller_pubkey = identity_public(74).public_key_hex; 5297 let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); 5298 let listing_event_id = "2".repeat(64); 5299 let stale_order_id = "018f47a8-7b2c-7000-8000-000000000012"; 5300 let stale_record_id = seed_app_order_record_variant( 5301 &sandbox, 5302 account_id, 5303 buyer_pubkey, 5304 seller_pubkey.as_str(), 5305 stale_order_id, 5306 listing_addr.as_str(), 5307 listing_event_id.as_str(), 5308 false, 5309 "supported", 5310 Vec::new(), 5311 ); 5312 5313 let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); 5314 assert_eq!( 5315 app_list["result"]["records"][0]["record_id"], 5316 stale_record_id 5317 ); 5318 assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false); 5319 assert_eq!(app_list["result"]["records"][0]["exportable"], false); 5320 assert!( 5321 app_list["result"]["records"][0]["reason"] 5322 .as_str() 5323 .expect("stale reason") 5324 .contains("not marked current") 5325 ); 5326 5327 let export_path = sandbox.root().join("stale-app-order.toml"); 5328 let export_path_arg = export_path.to_string_lossy(); 5329 let (output, stale_export) = sandbox.json_output(&[ 5330 "--format", 5331 "json", 5332 "order", 5333 "app", 5334 "export", 5335 stale_record_id.as_str(), 5336 "--output", 5337 export_path_arg.as_ref(), 5338 ]); 5339 assert!(!output.status.success()); 5340 assert_eq!(stale_export["operation_id"], "order.app.export"); 5341 assert_eq!(stale_export["result"], Value::Null); 5342 assert_eq!(stale_export["errors"][0]["detail"]["state"], "stale"); 5343 assert_eq!(stale_export["errors"][0]["detail"]["valid"], false); 5344 assert!(!export_path.exists()); 5345 5346 let (submit_output, submit) = sandbox.json_output(&[ 5347 "--format", 5348 "json", 5349 "--dry-run", 5350 "order", 5351 "submit", 5352 stale_record_id.as_str(), 5353 ]); 5354 assert!(!submit_output.status.success()); 5355 assert_eq!(submit_output.status.code(), Some(3)); 5356 assert_eq!(submit["operation_id"], "order.submit"); 5357 assert_eq!(submit["errors"][0]["code"], "operation_unavailable"); 5358 assert_eq!( 5359 submit["errors"][0]["detail"]["issues"][0]["code"], 5360 "app_order_stale" 5361 ); 5362 } 5363 5364 #[test] 5365 fn order_app_records_fail_closed_when_unsupported() { 5366 let sandbox = RadrootsCliSandbox::new(); 5367 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 5368 let account_id = account["result"]["account"]["id"] 5369 .as_str() 5370 .expect("account id"); 5371 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 5372 let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 5373 .as_str() 5374 .expect("buyer pubkey"); 5375 let seller_pubkey = identity_public(75).public_key_hex; 5376 let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); 5377 let listing_event_id = "3".repeat(64); 5378 let order_id = "018f47a8-7b2c-7000-8000-000000000013"; 5379 let record_id = seed_app_order_record_variant( 5380 &sandbox, 5381 account_id, 5382 buyer_pubkey, 5383 seller_pubkey.as_str(), 5384 order_id, 5385 listing_addr.as_str(), 5386 listing_event_id.as_str(), 5387 true, 5388 "unsupported", 5389 vec!["seller_pubkey_required"], 5390 ); 5391 5392 let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); 5393 assert_eq!(app_list["result"]["records"][0]["record_id"], record_id); 5394 assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false); 5395 assert_eq!(app_list["result"]["records"][0]["exportable"], false); 5396 assert!( 5397 app_list["result"]["records"][0]["reason"] 5398 .as_str() 5399 .expect("unsupported reason") 5400 .contains("not marked supported") 5401 ); 5402 5403 let export_path = sandbox.root().join("unsupported-app-order.toml"); 5404 let export_path_arg = export_path.to_string_lossy(); 5405 let (export_output, export) = sandbox.json_output(&[ 5406 "--format", 5407 "json", 5408 "order", 5409 "app", 5410 "export", 5411 record_id.as_str(), 5412 "--output", 5413 export_path_arg.as_ref(), 5414 ]); 5415 assert!(!export_output.status.success()); 5416 assert_eq!(export["operation_id"], "order.app.export"); 5417 assert_eq!(export["errors"][0]["detail"]["state"], "unsupported"); 5418 assert_eq!( 5419 export["errors"][0]["detail"]["issues"][0]["code"], 5420 "app_order_unsupported" 5421 ); 5422 assert!(!export_path.exists()); 5423 5424 let (submit_output, submit) = 5425 sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); 5426 assert!(!submit_output.status.success()); 5427 assert_eq!( 5428 submit["errors"][0]["detail"]["issues"][0]["code"], 5429 "app_order_unsupported" 5430 ); 5431 } 5432 5433 #[test] 5434 fn order_app_records_fail_closed_when_supported_record_is_malformed() { 5435 let sandbox = RadrootsCliSandbox::new(); 5436 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 5437 let account_id = account["result"]["account"]["id"] 5438 .as_str() 5439 .expect("account id"); 5440 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 5441 let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 5442 .as_str() 5443 .expect("buyer pubkey"); 5444 let seller_pubkey = identity_public(75).public_key_hex; 5445 let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); 5446 let listing_event_id = "3".repeat(64); 5447 let order_id = "018f47a8-7b2c-7000-8000-000000000014"; 5448 let record_id = seed_app_order_record_variant( 5449 &sandbox, 5450 account_id, 5451 buyer_pubkey, 5452 seller_pubkey.as_str(), 5453 order_id, 5454 listing_addr.as_str(), 5455 listing_event_id.as_str(), 5456 true, 5457 "supported", 5458 vec!["unit_price_required"], 5459 ); 5460 5461 let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); 5462 assert_eq!(app_list["result"]["records"][0]["record_id"], record_id); 5463 assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false); 5464 assert_eq!(app_list["result"]["records"][0]["exportable"], false); 5465 assert!( 5466 app_list["result"]["records"][0]["reason"] 5467 .as_str() 5468 .expect("malformed reason") 5469 .contains("support_status.issues") 5470 ); 5471 5472 let export_path = sandbox.root().join("malformed-app-order.toml"); 5473 let export_path_arg = export_path.to_string_lossy(); 5474 let (export_output, export) = sandbox.json_output(&[ 5475 "--format", 5476 "json", 5477 "order", 5478 "app", 5479 "export", 5480 record_id.as_str(), 5481 "--output", 5482 export_path_arg.as_ref(), 5483 ]); 5484 assert!(!export_output.status.success()); 5485 assert_eq!(export["operation_id"], "order.app.export"); 5486 assert_eq!(export["errors"][0]["detail"]["state"], "invalid"); 5487 assert_eq!( 5488 export["errors"][0]["detail"]["issues"][0]["code"], 5489 "invalid_app_order_record" 5490 ); 5491 assert!(!export_path.exists()); 5492 5493 let (submit_output, submit) = 5494 sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); 5495 assert!(!submit_output.status.success()); 5496 assert_eq!(submit["operation_id"], "order.submit"); 5497 assert_eq!( 5498 submit["errors"][0]["detail"]["issues"][0]["code"], 5499 "invalid_app_order_record" 5500 ); 5501 } 5502 5503 #[test] 5504 fn order_app_records_fail_closed_when_order_id_conflicts() { 5505 let sandbox = RadrootsCliSandbox::new(); 5506 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 5507 let account_id = account["result"]["account"]["id"] 5508 .as_str() 5509 .expect("account id"); 5510 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 5511 let buyer_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 5512 .as_str() 5513 .expect("buyer pubkey"); 5514 let seller_pubkey = identity_public(76).public_key_hex; 5515 let listing_addr = format!("30402:{seller_pubkey}:AAAAAAAAAAAAAAAAAAAAAQ"); 5516 let listing_event_id = "4".repeat(64); 5517 let order_id = "018f47a8-7b2c-7000-8000-000000000015"; 5518 let first_record_id = seed_app_order_record( 5519 &sandbox, 5520 account_id, 5521 buyer_pubkey, 5522 seller_pubkey.as_str(), 5523 order_id, 5524 listing_addr.as_str(), 5525 listing_event_id.as_str(), 5526 ); 5527 let conflicting_record_id = format!("app:local_work:order_request:{order_id}:conflict"); 5528 seed_app_order_record_variant_with_record_id( 5529 &sandbox, 5530 account_id, 5531 buyer_pubkey, 5532 seller_pubkey.as_str(), 5533 order_id, 5534 listing_addr.as_str(), 5535 listing_event_id.as_str(), 5536 conflicting_record_id.clone(), 5537 true, 5538 "supported", 5539 Vec::new(), 5540 ); 5541 5542 let app_list = sandbox.json_success(&["--format", "json", "order", "app", "list"]); 5543 assert_eq!(app_list["result"]["count"], 1); 5544 assert_eq!( 5545 app_list["result"]["records"][0]["record_id"], 5546 conflicting_record_id 5547 ); 5548 assert_eq!(app_list["result"]["records"][0]["ready_for_submit"], false); 5549 assert_eq!(app_list["result"]["records"][0]["exportable"], false); 5550 assert!( 5551 app_list["result"]["records"][0]["reason"] 5552 .as_str() 5553 .expect("conflict reason") 5554 .contains(first_record_id.as_str()) 5555 ); 5556 5557 let export_path = sandbox.root().join("conflicting-app-order.toml"); 5558 let export_path_arg = export_path.to_string_lossy(); 5559 let (export_output, export) = sandbox.json_output(&[ 5560 "--format", 5561 "json", 5562 "order", 5563 "app", 5564 "export", 5565 conflicting_record_id.as_str(), 5566 "--output", 5567 export_path_arg.as_ref(), 5568 ]); 5569 assert!(!export_output.status.success()); 5570 assert_eq!(export["operation_id"], "order.app.export"); 5571 assert_eq!(export["errors"][0]["detail"]["state"], "conflict"); 5572 assert_eq!( 5573 export["errors"][0]["detail"]["issues"][0]["code"], 5574 "app_order_conflict" 5575 ); 5576 assert!(!export_path.exists()); 5577 5578 let (submit_output, submit) = 5579 sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); 5580 assert!(!submit_output.status.success()); 5581 assert_eq!( 5582 submit["errors"][0]["detail"]["issues"][0]["code"], 5583 "app_order_conflict" 5584 ); 5585 } 5586 5587 #[test] 5588 fn farm_publish_uses_sdk_outbox_without_legacy_signed_event_records() { 5589 let sandbox = RadrootsCliSandbox::new(); 5590 sandbox.json_success(&["--format", "json", "account", "create"]); 5591 sandbox.json_success(&[ 5592 "--format", 5593 "json", 5594 "farm", 5595 "create", 5596 "--name", 5597 "Green Farm", 5598 "--location", 5599 "farmstand", 5600 "--country", 5601 "US", 5602 "--delivery-method", 5603 "pickup", 5604 ]); 5605 let relay_url = "ws://127.0.0.1:9"; 5606 let local_event_records_before_publish = sandbox.local_event_records().len(); 5607 5608 let (output, publish) = sandbox.json_output(&[ 5609 "--format", 5610 "json", 5611 "--relay", 5612 relay_url, 5613 "--approval-token", 5614 "approve", 5615 "farm", 5616 "publish", 5617 ]); 5618 5619 assert!(!output.status.success()); 5620 assert_eq!(publish["operation_id"], "farm.publish"); 5621 assert_eq!(publish["result"], serde_json::Value::Null); 5622 assert_eq!(publish["errors"][0]["code"], "network_unavailable"); 5623 let detail = &publish["errors"][0]["detail"]; 5624 assert_eq!(detail["source"], "SDK farm publish ยท configured signer"); 5625 assert_eq!(detail["state"], "unavailable"); 5626 assert_eq!(detail["profile"]["state"], "not_submitted"); 5627 assert_eq!(detail["profile"]["event_id"], serde_json::Value::Null); 5628 assert_eq!(detail["farm"]["state"], "unavailable"); 5629 assert_eq!(detail["farm"]["target_relays"][0], relay_url); 5630 assert_eq!(detail["farm"]["failed_relays"][0]["relay"], relay_url); 5631 assert_eq!( 5632 detail["farm"]["event_id"] 5633 .as_str() 5634 .expect("sdk farm event id") 5635 .len(), 5636 64 5637 ); 5638 5639 let records = sandbox.local_event_records(); 5640 assert_eq!(records.len(), local_event_records_before_publish); 5641 let signed_records = records 5642 .iter() 5643 .filter(|record| record.family == LocalRecordFamily::SignedEvent) 5644 .collect::<Vec<_>>(); 5645 assert!(signed_records.is_empty()); 5646 } 5647 5648 #[test] 5649 fn listing_publish_failure_uses_sdk_outbox_without_legacy_local_event_record() { 5650 let sandbox = RadrootsCliSandbox::new(); 5651 sandbox.json_success(&["--format", "json", "account", "create"]); 5652 let farm = sandbox.json_success(&[ 5653 "--format", 5654 "json", 5655 "farm", 5656 "create", 5657 "--name", 5658 "Green Farm", 5659 "--location", 5660 "farmstand", 5661 "--country", 5662 "US", 5663 "--delivery-method", 5664 "pickup", 5665 ]); 5666 let farm_d_tag = farm["result"]["config"]["farm_d_tag"] 5667 .as_str() 5668 .expect("farm d tag"); 5669 let listing_file = create_listing_draft(&sandbox, "failed-outbox-eggs"); 5670 make_listing_publishable(&listing_file, farm_d_tag); 5671 let relay_url = "ws://127.0.0.1:9"; 5672 let local_event_records_before_publish = sandbox.local_event_records().len(); 5673 5674 let (output, publish) = sandbox.json_output(&[ 5675 "--format", 5676 "json", 5677 "--relay", 5678 relay_url, 5679 "--approval-token", 5680 "approve", 5681 "listing", 5682 "publish", 5683 listing_file.to_string_lossy().as_ref(), 5684 ]); 5685 5686 assert!(!output.status.success()); 5687 assert_eq!(publish["operation_id"], "listing.publish"); 5688 assert_eq!(publish["errors"][0]["code"], "network_unavailable"); 5689 assert_eq!( 5690 publish["errors"][0]["detail"]["source"], 5691 "SDK listing publish ยท configured signer" 5692 ); 5693 assert_eq!(publish["errors"][0]["detail"]["state"], "unavailable"); 5694 assert_eq!( 5695 publish["errors"][0]["detail"]["target_relays"][0], 5696 relay_url 5697 ); 5698 assert_eq!( 5699 publish["errors"][0]["detail"]["failed_relays"][0]["relay"], 5700 relay_url 5701 ); 5702 assert_eq!( 5703 publish["errors"][0]["detail"]["actions"][0], 5704 "radroots sync push" 5705 ); 5706 assert_eq!( 5707 publish["errors"][0]["detail"]["event_id"] 5708 .as_str() 5709 .expect("sdk event id") 5710 .len(), 5711 64 5712 ); 5713 assert_eq!( 5714 sandbox.local_event_records().len(), 5715 local_event_records_before_publish 5716 ); 5717 } 5718 5719 #[test] 5720 fn sync_push_sdk_outbox_failure_reports_network_unavailable() { 5721 let sandbox = RadrootsCliSandbox::new(); 5722 sandbox.json_success(&["--format", "json", "account", "create"]); 5723 let farm = sandbox.json_success(&[ 5724 "--format", 5725 "json", 5726 "farm", 5727 "create", 5728 "--name", 5729 "Sync SDK Farm", 5730 "--location", 5731 "farmstand", 5732 "--country", 5733 "US", 5734 "--delivery-method", 5735 "pickup", 5736 ]); 5737 let farm_d_tag = farm["result"]["config"]["farm_d_tag"] 5738 .as_str() 5739 .expect("farm d tag"); 5740 let listing_file = create_listing_draft(&sandbox, "sync-sdk-push-eggs"); 5741 make_listing_publishable(&listing_file, farm_d_tag); 5742 let relay = "ws://127.0.0.1:9"; 5743 let publish = sandbox.json_success(&[ 5744 "--format", 5745 "json", 5746 "--offline", 5747 "--relay", 5748 relay, 5749 "--approval-token", 5750 "approve", 5751 "listing", 5752 "publish", 5753 listing_file.to_string_lossy().as_ref(), 5754 ]); 5755 5756 assert_eq!(publish["operation_id"], "listing.publish"); 5757 assert_eq!(publish["result"]["state"], "queued"); 5758 let status = sandbox.json_success(&["--format", "json", "sync", "status", "get"]); 5759 assert_eq!( 5760 status["result"]["source"], 5761 "SDK canonical event store and outbox" 5762 ); 5763 assert_eq!(status["result"]["replica_db"], "legacy_derived_not_checked"); 5764 assert_eq!(status["result"]["queue"]["pending_count"], 1); 5765 assert_eq!(status["result"]["queue"]["ready_signed_count"], 1); 5766 5767 let (output, value) = sandbox.json_output(&[ 5768 "--format", 5769 "json", 5770 "--relay", 5771 relay, 5772 "--approval-token", 5773 "approve", 5774 "sync", 5775 "push", 5776 ]); 5777 5778 assert!(!output.status.success(), "{value}"); 5779 assert_eq!(value["operation_id"], "sync.push"); 5780 assert_eq!(value["result"], Value::Null); 5781 assert_eq!(value["errors"][0]["code"], "network_unavailable", "{value}"); 5782 assert_eq!(value["errors"][0]["detail"]["state"], "unavailable"); 5783 assert_eq!(value["errors"][0]["detail"]["source"], "SDK outbox push"); 5784 assert_eq!( 5785 value["errors"][0]["detail"]["replica_db"], 5786 "legacy_derived_not_checked" 5787 ); 5788 assert_eq!(value["errors"][0]["detail"]["target_relays"][0], relay); 5789 assert_eq!( 5790 value["errors"][0]["detail"]["failed_relays"][0]["relay"], 5791 relay 5792 ); 5793 assert_eq!(value["errors"][0]["detail"]["publishable_count"], 1); 5794 assert_eq!(value["errors"][0]["detail"]["published_count"], 0); 5795 assert_eq!(value["errors"][0]["detail"]["failed_count"], 1); 5796 assert_eq!( 5797 value["errors"][0]["detail"]["reason"], 5798 "SDK outbox push did not reach accepted quorum for any ready event" 5799 ); 5800 assert_eq!( 5801 value["errors"][0]["detail"]["actions"][0], 5802 "radroots sync push" 5803 ); 5804 assert_no_removed_command_reference(&value, &["sync", "push"]); 5805 assert_no_daemon_runtime_reference(&value, &["sync", "push"]); 5806 } 5807 5808 #[test] 5809 fn sync_push_ignores_legacy_replica_pending_queue_for_sdk_canonical_push() { 5810 let sandbox = RadrootsCliSandbox::new(); 5811 sandbox.json_success(&["--format", "json", "account", "create"]); 5812 sandbox.json_success(&["--format", "json", "store", "init"]); 5813 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 5814 let selected_pubkey = signer["result"]["local"]["public_identity"]["public_key_hex"] 5815 .as_str() 5816 .expect("selected public key"); 5817 seed_legacy_replica_sync_farm(&sandbox, LEGACY_SYNC_PUSH_FARM_D_TAG, selected_pubkey); 5818 let executor = SqliteExecutor::open(sandbox.replica_db_path()).expect("open replica"); 5819 let legacy_batch = 5820 radroots_replica_pending_publish_batch(&executor).expect("legacy pending batch"); 5821 assert!(legacy_batch.pending_count > 0); 5822 5823 let value = sandbox.json_success(&[ 5824 "--format", 5825 "json", 5826 "--relay", 5827 "ws://127.0.0.1:9", 5828 "--approval-token", 5829 "approve", 5830 "sync", 5831 "push", 5832 ]); 5833 5834 assert_eq!(value["operation_id"], "sync.push"); 5835 assert_eq!(value["result"]["state"], "ready"); 5836 assert_eq!(value["result"]["source"], "SDK outbox push"); 5837 assert_eq!(value["result"]["replica_db"], "legacy_derived_not_checked"); 5838 assert_eq!(value["result"]["queue"]["pending_count"], 0); 5839 assert_eq!(value["result"]["queue"]["total_count"], 0); 5840 assert_eq!(value["result"]["publishable_count"], 0); 5841 assert_eq!(value["result"]["published_count"], 0); 5842 assert_eq!(value["result"]["failed_count"], 0); 5843 assert_eq!( 5844 value["result"]["reason"], 5845 "SDK outbox had no ready signed events to push" 5846 ); 5847 assert_no_removed_command_reference(&value, &["sync", "push"]); 5848 assert_no_daemon_runtime_reference(&value, &["sync", "push"]); 5849 } 5850 5851 #[test] 5852 fn buyer_market_sync_basket_dry_runs_preflight_without_mutating_local_state() { 5853 let sandbox = RadrootsCliSandbox::new(); 5854 sandbox.json_success(&["--format", "json", "account", "create"]); 5855 5856 let market = sandbox.json_success(&["--format", "json", "--dry-run", "market", "refresh"]); 5857 assert_eq!(market["operation_id"], "market.refresh"); 5858 assert_eq!(market["dry_run"], true); 5859 assert_eq!(market["result"]["state"], "unconfigured"); 5860 assert_eq!(market["result"]["replica_db"], "missing"); 5861 5862 let (sync_pull_output, sync_pull) = 5863 sandbox.json_output(&["--format", "json", "--dry-run", "sync", "pull"]); 5864 assert!(!sync_pull_output.status.success()); 5865 assert_eq!(sync_pull["operation_id"], "sync.pull"); 5866 assert_eq!(sync_pull["dry_run"], true); 5867 assert_eq!(sync_pull["errors"][0]["code"], "operation_unavailable"); 5868 assert_eq!(sync_pull["errors"][0]["detail"]["state"], "unconfigured"); 5869 assert_eq!(sync_pull["errors"][0]["detail"]["replica_db"], "missing"); 5870 5871 let sync_push = sandbox.json_success(&["--format", "json", "--dry-run", "sync", "push"]); 5872 assert_eq!(sync_push["operation_id"], "sync.push"); 5873 assert_eq!(sync_push["dry_run"], true); 5874 assert_eq!(sync_push["result"]["state"], "ready"); 5875 assert_eq!(sync_push["result"]["source"], "SDK outbox push"); 5876 assert_eq!( 5877 sync_push["result"]["replica_db"], 5878 "legacy_derived_not_checked" 5879 ); 5880 assert_eq!(sync_push["result"]["queue"]["pending_count"], 0); 5881 assert_eq!(sync_push["result"]["queue"]["total_count"], 0); 5882 assert_eq!(sync_push["result"]["publishable_count"], 0); 5883 assert_eq!(sync_push["result"]["published_count"], 0); 5884 5885 sandbox.json_success(&["--format", "json", "store", "init"]); 5886 let relay_refresh = sandbox.json_success(&[ 5887 "--format", 5888 "json", 5889 "--relay", 5890 "ws://127.0.0.1:9", 5891 "--dry-run", 5892 "market", 5893 "refresh", 5894 ]); 5895 assert_eq!(relay_refresh["operation_id"], "market.refresh"); 5896 assert_eq!(relay_refresh["dry_run"], true); 5897 assert_eq!(relay_refresh["result"]["state"], "ready"); 5898 assert_eq!( 5899 relay_refresh["result"]["target_relays"][0], 5900 "ws://127.0.0.1:9" 5901 ); 5902 assert_eq!(relay_refresh["result"]["fetched_count"], 0); 5903 assert_eq!(relay_refresh["result"]["ingested_count"], 0); 5904 5905 let sync_push_ready = sandbox.json_success(&[ 5906 "--format", 5907 "json", 5908 "--relay", 5909 "ws://127.0.0.1:9", 5910 "--dry-run", 5911 "sync", 5912 "push", 5913 ]); 5914 assert_eq!(sync_push_ready["operation_id"], "sync.push"); 5915 assert_eq!(sync_push_ready["dry_run"], true); 5916 assert_eq!(sync_push_ready["result"]["state"], "ready"); 5917 assert_eq!(sync_push_ready["result"]["source"], "SDK outbox push"); 5918 assert_eq!( 5919 sync_push_ready["result"]["replica_db"], 5920 "legacy_derived_not_checked" 5921 ); 5922 assert_eq!( 5923 sync_push_ready["result"]["target_relays"][0], 5924 "ws://127.0.0.1:9" 5925 ); 5926 assert_eq!(sync_push_ready["result"]["publishable_count"], 0); 5927 assert_eq!(sync_push_ready["result"]["published_count"], 0); 5928 5929 let empty_search = 5930 sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); 5931 assert_eq!(empty_search["operation_id"], "market.product.search"); 5932 assert_eq!(empty_search["result"]["state"], "empty"); 5933 5934 let create_dry_run = sandbox.json_success(&[ 5935 "--format", 5936 "json", 5937 "--dry-run", 5938 "basket", 5939 "create", 5940 "basket_probe", 5941 ]); 5942 let basket_file = create_dry_run["result"]["file"] 5943 .as_str() 5944 .expect("basket file"); 5945 assert_eq!(create_dry_run["operation_id"], "basket.create"); 5946 assert_eq!(create_dry_run["result"]["state"], "dry_run"); 5947 assert!(!Path::new(basket_file).exists()); 5948 5949 sandbox.json_success(&["--format", "json", "basket", "create", "basket_probe"]); 5950 let (collision_output, collision) = sandbox.json_output(&[ 5951 "--format", 5952 "json", 5953 "--dry-run", 5954 "basket", 5955 "create", 5956 "basket_probe", 5957 ]); 5958 assert!(!collision_output.status.success()); 5959 assert_eq!(collision["operation_id"], "basket.create"); 5960 assert_eq!(collision["errors"][0]["code"], "invalid_input"); 5961 5962 let before_add = sandbox.json_success(&["--format", "json", "basket", "get", "basket_probe"]); 5963 let add = sandbox.json_success(&[ 5964 "--format", 5965 "json", 5966 "--dry-run", 5967 "basket", 5968 "item", 5969 "add", 5970 "basket_probe", 5971 "--listing-addr", 5972 LISTING_ADDR, 5973 "--bin-id", 5974 "bin-1", 5975 "--quantity", 5976 "2", 5977 ]); 5978 assert_eq!(add["operation_id"], "basket.item.add"); 5979 assert_eq!(add["result"]["state"], "dry_run"); 5980 let after_add = sandbox.json_success(&["--format", "json", "basket", "get", "basket_probe"]); 5981 assert_eq!(after_add["result"], before_add["result"]); 5982 5983 sandbox.json_success(&[ 5984 "--format", 5985 "json", 5986 "basket", 5987 "item", 5988 "add", 5989 "basket_probe", 5990 "--listing-addr", 5991 LISTING_ADDR, 5992 "--bin-id", 5993 "bin-1", 5994 "--quantity", 5995 "2", 5996 ]); 5997 let quote = sandbox.json_success(&[ 5998 "--format", 5999 "json", 6000 "--dry-run", 6001 "basket", 6002 "quote", 6003 "create", 6004 "basket_probe", 6005 ]); 6006 assert_eq!(quote["operation_id"], "basket.quote.create"); 6007 assert_eq!(quote["result"]["state"], "unconfigured"); 6008 assert_eq!(quote["result"]["ready_for_quote"], false); 6009 assert_eq!( 6010 quote["result"]["issues"][0]["code"], 6011 "basket_item_listing_unresolved" 6012 ); 6013 6014 let basket_after_quote = 6015 sandbox.json_success(&["--format", "json", "basket", "get", "basket_probe"]); 6016 assert_eq!(basket_after_quote["result"]["quote"], Value::Null); 6017 } 6018 6019 #[test] 6020 fn market_order_request_readiness_gates_buyer_intent_actions() { 6021 let sandbox = RadrootsCliSandbox::new(); 6022 seed_orderable_listing(&sandbox, LISTING_ADDR); 6023 6024 let search = sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); 6025 assert_eq!(search["operation_id"], "market.product.search"); 6026 let result = &search["result"]["results"][0]; 6027 assert_eq!(result["protocol_valid"], true); 6028 assert_eq!(result["marketplace_eligible"], true); 6029 assert_eq!(result["order_request_enabled"], true); 6030 assert_eq!(result["primary_bin_verified"], true); 6031 assert!(result.get("reason_codes").is_none()); 6032 assert!( 6033 search["result"]["actions"] 6034 .as_array() 6035 .expect("search actions") 6036 .iter() 6037 .any(|action| action == "radroots basket create") 6038 ); 6039 6040 let listing = sandbox.json_success(&[ 6041 "--format", 6042 "json", 6043 "market", 6044 "listing", 6045 "get", 6046 "pasture-eggs", 6047 ]); 6048 assert_eq!(listing["operation_id"], "market.listing.get"); 6049 assert_eq!(listing["result"]["protocol_valid"], true); 6050 assert_eq!(listing["result"]["marketplace_eligible"], true); 6051 assert_eq!(listing["result"]["order_request_enabled"], true); 6052 assert_eq!(listing["result"]["primary_bin_verified"], true); 6053 assert!( 6054 listing["result"]["actions"] 6055 .as_array() 6056 .expect("listing actions") 6057 .iter() 6058 .any(|action| action == "radroots basket create") 6059 ); 6060 6061 update_orderable_listing_available_amount(&sandbox, LISTING_ADDR, 0); 6062 6063 let disabled_search = 6064 sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); 6065 let disabled_result = &disabled_search["result"]["results"][0]; 6066 assert_eq!(disabled_result["protocol_valid"], true); 6067 assert_eq!(disabled_result["marketplace_eligible"], true); 6068 assert_eq!(disabled_result["order_request_enabled"], false); 6069 assert_eq!(disabled_result["primary_bin_verified"], true); 6070 assert_eq!( 6071 disabled_result["reason_codes"][0], 6072 "listing_order_request_disabled" 6073 ); 6074 assert_eq!( 6075 disabled_result["reason_codes"][1], 6076 "listing_inventory_unavailable" 6077 ); 6078 assert!( 6079 disabled_search["result"]["actions"] 6080 .as_array() 6081 .expect("disabled search actions") 6082 .iter() 6083 .all(|action| action != "radroots basket create") 6084 ); 6085 6086 let disabled_listing = sandbox.json_success(&[ 6087 "--format", 6088 "json", 6089 "market", 6090 "listing", 6091 "get", 6092 "pasture-eggs", 6093 ]); 6094 assert_eq!(disabled_listing["result"]["protocol_valid"], true); 6095 assert_eq!(disabled_listing["result"]["marketplace_eligible"], true); 6096 assert_eq!(disabled_listing["result"]["order_request_enabled"], false); 6097 assert_eq!(disabled_listing["result"]["primary_bin_verified"], true); 6098 assert_eq!( 6099 disabled_listing["result"]["reason_codes"][0], 6100 "listing_order_request_disabled" 6101 ); 6102 assert!( 6103 disabled_listing["result"] 6104 .get("actions") 6105 .and_then(Value::as_array) 6106 .is_none_or(Vec::is_empty) 6107 ); 6108 6109 update_orderable_listing_available_amount(&sandbox, LISTING_ADDR, 5); 6110 update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, None); 6111 6112 let no_bin_search = 6113 sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); 6114 let no_bin_result = &no_bin_search["result"]["results"][0]; 6115 assert_eq!(no_bin_result["primary_bin_id"], Value::Null); 6116 assert_eq!(no_bin_result["order_request_enabled"], false); 6117 assert_eq!(no_bin_result["primary_bin_verified"], false); 6118 assert_eq!( 6119 no_bin_result["reason_codes"][0], 6120 "listing_order_request_disabled" 6121 ); 6122 assert_eq!( 6123 no_bin_result["reason_codes"][1], 6124 "listing_primary_bin_missing" 6125 ); 6126 assert!( 6127 no_bin_search["result"]["actions"] 6128 .as_array() 6129 .expect("no-bin search actions") 6130 .iter() 6131 .all(|action| action != "radroots basket create") 6132 ); 6133 6134 let no_bin_listing = sandbox.json_success(&[ 6135 "--format", 6136 "json", 6137 "market", 6138 "listing", 6139 "get", 6140 "pasture-eggs", 6141 ]); 6142 assert_eq!(no_bin_listing["result"]["primary_bin_id"], Value::Null); 6143 assert_eq!(no_bin_listing["result"]["order_request_enabled"], false); 6144 assert_eq!(no_bin_listing["result"]["primary_bin_verified"], false); 6145 assert_eq!( 6146 no_bin_listing["result"]["reason_codes"][1], 6147 "listing_primary_bin_missing" 6148 ); 6149 assert!( 6150 no_bin_listing["result"] 6151 .get("actions") 6152 .and_then(Value::as_array) 6153 .is_none_or(Vec::is_empty) 6154 ); 6155 6156 update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("missing-bin")); 6157 6158 let invalid_bin_search = 6159 sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); 6160 let invalid_bin_result = &invalid_bin_search["result"]["results"][0]; 6161 assert_eq!(invalid_bin_result["primary_bin_id"], "missing-bin"); 6162 assert_eq!(invalid_bin_result["order_request_enabled"], false); 6163 assert_eq!(invalid_bin_result["primary_bin_verified"], false); 6164 assert_eq!( 6165 invalid_bin_result["reason_codes"][1], 6166 "listing_primary_bin_invalid" 6167 ); 6168 assert!( 6169 invalid_bin_search["result"]["actions"] 6170 .as_array() 6171 .expect("invalid-bin search actions") 6172 .iter() 6173 .all(|action| action != "radroots basket create") 6174 ); 6175 6176 let invalid_bin_listing = sandbox.json_success(&[ 6177 "--format", 6178 "json", 6179 "market", 6180 "listing", 6181 "get", 6182 "pasture-eggs", 6183 ]); 6184 assert_eq!( 6185 invalid_bin_listing["result"]["primary_bin_id"], 6186 "missing-bin" 6187 ); 6188 assert_eq!( 6189 invalid_bin_listing["result"]["order_request_enabled"], 6190 false 6191 ); 6192 assert_eq!(invalid_bin_listing["result"]["primary_bin_verified"], false); 6193 assert_eq!( 6194 invalid_bin_listing["result"]["reason_codes"][1], 6195 "listing_primary_bin_invalid" 6196 ); 6197 assert!( 6198 invalid_bin_listing["result"] 6199 .get("actions") 6200 .and_then(Value::as_array) 6201 .is_none_or(Vec::is_empty) 6202 ); 6203 6204 update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("bin-1")); 6205 6206 let restored_listing = sandbox.json_success(&[ 6207 "--format", 6208 "json", 6209 "market", 6210 "listing", 6211 "get", 6212 "pasture-eggs", 6213 ]); 6214 assert_eq!(restored_listing["result"]["primary_bin_id"], "bin-1"); 6215 assert_eq!(restored_listing["result"]["order_request_enabled"], true); 6216 assert_eq!(restored_listing["result"]["primary_bin_verified"], true); 6217 assert!( 6218 restored_listing["result"] 6219 .get("reason_codes") 6220 .is_none_or(Value::is_null) 6221 ); 6222 assert!( 6223 restored_listing["result"]["actions"] 6224 .as_array() 6225 .expect("restored listing actions") 6226 .iter() 6227 .any(|action| action == "radroots basket create") 6228 ); 6229 } 6230 6231 #[test] 6232 fn required_approval_token_rejects_absent_empty_and_whitespace_values() { 6233 let sandbox = RadrootsCliSandbox::new(); 6234 let public_identity = identity_public(61); 6235 let public_identity_file = 6236 write_public_identity_profile(&sandbox, "approval-import", &public_identity); 6237 let public_identity_path = public_identity_file.to_string_lossy(); 6238 6239 assert_required_approval_token_rejected( 6240 &sandbox, 6241 "account.import", 6242 &["account", "import", public_identity_path.as_ref()], 6243 ); 6244 assert_required_approval_token_rejected( 6245 &sandbox, 6246 "account.remove", 6247 &["account", "remove", "acct_missing"], 6248 ); 6249 assert_required_approval_token_rejected( 6250 &sandbox, 6251 "farm.rebind", 6252 &["farm", "rebind", "acct_missing"], 6253 ); 6254 assert_required_approval_token_rejected(&sandbox, "farm.publish", &["farm", "publish"]); 6255 assert_required_approval_token_rejected( 6256 &sandbox, 6257 "listing.publish", 6258 &["listing", "publish", "missing-listing.toml"], 6259 ); 6260 assert_required_approval_token_rejected( 6261 &sandbox, 6262 "listing.update", 6263 &["listing", "update", "missing-listing.toml"], 6264 ); 6265 assert_required_approval_token_rejected( 6266 &sandbox, 6267 "listing.archive", 6268 &["listing", "archive", "missing-listing.toml"], 6269 ); 6270 assert_required_approval_token_rejected( 6271 &sandbox, 6272 "sync.push", 6273 &["--relay", "ws://127.0.0.1:9", "sync", "push"], 6274 ); 6275 assert_required_approval_token_rejected(&sandbox, "order.submit", &["order", "submit"]); 6276 assert_required_approval_token_rejected( 6277 &sandbox, 6278 "order.rebind", 6279 &["order", "rebind", "ord_missing", "acct_missing"], 6280 ); 6281 assert_required_approval_token_rejected(&sandbox, "order.accept", &["order", "accept"]); 6282 assert_required_approval_token_rejected( 6283 &sandbox, 6284 "order.decline", 6285 &["order", "decline", "--reason", "out_of_stock"], 6286 ); 6287 assert_required_approval_token_rejected( 6288 &sandbox, 6289 "order.cancel", 6290 &["order", "cancel", "--reason", "changed plans"], 6291 ); 6292 assert_required_approval_token_rejected( 6293 &sandbox, 6294 "order.revision.accept", 6295 &[ 6296 "order", 6297 "revision", 6298 "accept", 6299 "ord_pending", 6300 "--revision-id", 6301 "rev_pending", 6302 ], 6303 ); 6304 assert_required_approval_token_rejected( 6305 &sandbox, 6306 "order.revision.decline", 6307 &[ 6308 "order", 6309 "revision", 6310 "decline", 6311 "ord_pending", 6312 "--revision-id", 6313 "rev_pending", 6314 "--reason", 6315 "keep original order", 6316 ], 6317 ); 6318 } 6319 6320 fn assert_required_approval_token_rejected( 6321 sandbox: &RadrootsCliSandbox, 6322 operation_id: &str, 6323 command_args: &[&str], 6324 ) { 6325 for token in [None, Some(""), Some(" \t ")] { 6326 let mut args = vec!["--format", "json"]; 6327 if let Some(token) = token { 6328 args.push("--approval-token"); 6329 args.push(token); 6330 } 6331 args.extend_from_slice(command_args); 6332 6333 let (output, value) = sandbox.json_output(&args); 6334 6335 assert_eq!(output.status.code(), Some(6), "`{args:?}` should fail"); 6336 assert_eq!(value["operation_id"], operation_id); 6337 assert_eq!(value["errors"][0]["code"], "approval_required"); 6338 assert_eq!(value["errors"][0]["exit_code"], 6); 6339 assert_no_removed_command_reference(&value, &args); 6340 } 6341 } 6342 6343 #[test] 6344 fn order_submit_missing_order_returns_not_found_while_read_view_stays_successful() { 6345 let sandbox = RadrootsCliSandbox::new(); 6346 6347 let get = sandbox.json_success(&[ 6348 "--format", 6349 "json", 6350 "order", 6351 "get", 6352 "ord_missing_submit_target", 6353 ]); 6354 assert_eq!(get["operation_id"], "order.get"); 6355 assert_eq!(get["result"]["state"], "missing"); 6356 assert_eq!(get["errors"].as_array().expect("errors").len(), 0); 6357 6358 let (output, submit) = sandbox.json_output(&[ 6359 "--format", 6360 "json", 6361 "--approval-token", 6362 "approve", 6363 "order", 6364 "submit", 6365 "ord_missing_submit_target", 6366 ]); 6367 6368 assert_eq!(output.status.code(), Some(4)); 6369 assert_eq!(submit["operation_id"], "order.submit"); 6370 assert_eq!(submit["errors"][0]["code"], "not_found"); 6371 assert_eq!(submit["errors"][0]["exit_code"], 4); 6372 assert_eq!(submit["errors"][0]["detail"]["class"], "resource"); 6373 assert_no_removed_command_reference(&submit, &["order", "submit"]); 6374 assert_no_daemon_runtime_reference(&submit, &["order", "submit"]); 6375 } 6376 6377 fn create_ready_order(sandbox: &RadrootsCliSandbox, basket_id: &str) -> String { 6378 sandbox.json_success(&["--format", "json", "account", "create"]); 6379 seed_orderable_listing(sandbox, LISTING_ADDR); 6380 sandbox.json_success(&["--format", "json", "basket", "create", basket_id]); 6381 sandbox.json_success(&[ 6382 "--format", 6383 "json", 6384 "basket", 6385 "item", 6386 "add", 6387 basket_id, 6388 "--listing-addr", 6389 LISTING_ADDR, 6390 "--bin-id", 6391 "bin-1", 6392 "--quantity", 6393 "2", 6394 ]); 6395 let quote = sandbox.json_success(&["--format", "json", "basket", "quote", "create", basket_id]); 6396 quote["result"]["quote"]["order_id"] 6397 .as_str() 6398 .expect("order id") 6399 .to_owned() 6400 } 6401 6402 fn rewrite_order_bin(sandbox: &RadrootsCliSandbox, order_id: &str, bin_id: &str) { 6403 let path = sandbox 6404 .root() 6405 .join("data/apps/cli/orders/drafts") 6406 .join(format!("{order_id}.toml")); 6407 let contents = fs::read_to_string(&path).expect("read order draft"); 6408 let updated = contents.replace( 6409 "bin_id = \"bin-1\"", 6410 format!("bin_id = \"{bin_id}\"").as_str(), 6411 ); 6412 assert_ne!(updated, contents); 6413 fs::write(path, updated).expect("rewrite order draft bin"); 6414 } 6415 6416 fn rewrite_order_buyer_actor_pubkey(sandbox: &RadrootsCliSandbox, order_id: &str, pubkey: &str) { 6417 let path = sandbox 6418 .root() 6419 .join("data/apps/cli/orders/drafts") 6420 .join(format!("{order_id}.toml")); 6421 let contents = fs::read_to_string(&path).expect("read order draft"); 6422 let mut in_buyer_actor = false; 6423 let mut replaced = false; 6424 let updated = contents 6425 .lines() 6426 .map(|line| { 6427 let trimmed = line.trim_start(); 6428 if trimmed.starts_with('[') { 6429 in_buyer_actor = trimmed == "[buyer_actor]"; 6430 } 6431 if in_buyer_actor && trimmed.starts_with("pubkey =") { 6432 replaced = true; 6433 format!("{}pubkey = \"{}\"", line_indent(line), pubkey) 6434 } else { 6435 line.to_owned() 6436 } 6437 }) 6438 .collect::<Vec<_>>() 6439 .join("\n"); 6440 assert!(replaced, "buyer_actor pubkey field"); 6441 fs::write(path, format!("{updated}\n")).expect("rewrite order draft buyer actor"); 6442 } 6443 6444 fn line_indent(line: &str) -> &str { 6445 let trimmed = line.trim_start(); 6446 &line[..line.len() - trimmed.len()] 6447 } 6448 6449 fn signed_order_request_event_for_quote( 6450 buyer: &radroots_identity::RadrootsIdentity, 6451 order_id: &str, 6452 listing_event_id: &str, 6453 economics: RadrootsOrderEconomics, 6454 ) -> RadrootsNostrEvent { 6455 let buyer_pubkey = buyer.public_key_hex(); 6456 let seller_pubkey = "1".repeat(64); 6457 let payload = RadrootsOrderRequest { 6458 order_id: test_order_id(order_id), 6459 listing_addr: test_listing_addr(LISTING_ADDR), 6460 buyer_pubkey: test_pubkey(buyer_pubkey.as_str()), 6461 seller_pubkey: test_pubkey(seller_pubkey.as_str()), 6462 items: vec![RadrootsOrderItem { 6463 bin_id: test_inventory_bin_id("bin-1"), 6464 bin_count: 2, 6465 }], 6466 economics, 6467 }; 6468 let parts = order_request_event_build( 6469 &RadrootsNostrEventPtr { 6470 id: listing_event_id.to_owned(), 6471 relays: None, 6472 }, 6473 &payload, 6474 ) 6475 .expect("order request parts"); 6476 radroots_nostr_build_event(parts.kind, parts.content, parts.tags) 6477 .expect("nostr event builder") 6478 .sign_with_keys(buyer.keys()) 6479 .expect("signed order request") 6480 } 6481 6482 #[test] 6483 fn buyer_target_flow_acceptance_uses_target_operations() { 6484 let sandbox = RadrootsCliSandbox::new(); 6485 6486 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 6487 let account_id = account["result"]["account"]["id"] 6488 .as_str() 6489 .expect("account id"); 6490 assert_eq!(account["operation_id"], "account.create"); 6491 assert_eq!(account["result"]["account"]["signer"], "local"); 6492 assert_eq!(account["result"]["account"]["custody"], "secret_backed"); 6493 assert_eq!(account["result"]["account"]["write_capable"], true); 6494 assert_no_removed_command_reference(&account, &["account", "create"]); 6495 6496 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 6497 assert_eq!(signer["operation_id"], "signer.status.get"); 6498 assert_eq!(signer["result"]["mode"], "local"); 6499 assert_eq!(signer["result"]["state"], "ready"); 6500 assert_eq!(signer["result"]["signer_account_id"], account_id); 6501 assert_no_removed_command_reference(&signer, &["signer", "status", "get"]); 6502 6503 let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR); 6504 6505 let search = sandbox.json_success(&["--format", "json", "market", "product", "search", "eggs"]); 6506 assert_eq!(search["operation_id"], "market.product.search"); 6507 assert_eq!(search["errors"].as_array().expect("errors").len(), 0); 6508 assert_no_removed_command_reference(&search, &["market", "product", "search"]); 6509 6510 let create = sandbox.json_success(&["--format", "json", "basket", "create", "basket_flow"]); 6511 assert_eq!(create["operation_id"], "basket.create"); 6512 assert_eq!(create["result"]["basket_id"], "basket_flow"); 6513 assert_no_removed_command_reference(&create, &["basket", "create"]); 6514 6515 let add = sandbox.json_success(&[ 6516 "--format", 6517 "json", 6518 "basket", 6519 "item", 6520 "add", 6521 "basket_flow", 6522 "--listing-addr", 6523 LISTING_ADDR, 6524 "--bin-id", 6525 "bin-1", 6526 "--quantity", 6527 "2", 6528 ]); 6529 assert_eq!(add["operation_id"], "basket.item.add"); 6530 assert_eq!(add["result"]["ready_for_quote"], true); 6531 assert_no_removed_command_reference(&add, &["basket", "item", "add"]); 6532 6533 let quote = sandbox.json_success(&[ 6534 "--format", 6535 "json", 6536 "basket", 6537 "quote", 6538 "create", 6539 "basket_flow", 6540 ]); 6541 assert_eq!(quote["operation_id"], "basket.quote.create"); 6542 assert_eq!(quote["result"]["state"], "quoted"); 6543 assert_no_removed_command_reference("e, &["basket", "quote", "create"]); 6544 let order_id = quote["result"]["quote"]["order_id"] 6545 .as_str() 6546 .expect("order id"); 6547 let quote_economics = "e["result"]["quote"]["economics"]; 6548 let order_file = quote["result"]["order"]["file"] 6549 .as_str() 6550 .expect("order file"); 6551 assert_eq!(quote["result"]["quote"]["ready_for_submit"], true); 6552 assert_eq!(quote["result"]["quote"]["quote_version"], 1); 6553 assert_eq!( 6554 quote["result"]["quote"]["quote_id"], 6555 quote_economics["quote_id"] 6556 ); 6557 assert_eq!(quote_economics["quote_version"], 1); 6558 assert_eq!(quote_economics["pricing_basis"], "listing_event"); 6559 assert_eq!(quote_economics["currency"], "USD"); 6560 assert_eq!(quote_economics["items"][0]["bin_id"], "bin-1"); 6561 assert_eq!(quote_economics["items"][0]["bin_count"], 2); 6562 assert_eq!(quote_economics["discounts"], Value::Array(Vec::new())); 6563 assert_eq!(quote_economics["adjustments"], Value::Array(Vec::new())); 6564 assert_eq!( 6565 quote["result"]["order"]["economics"], 6566 quote_economics.clone() 6567 ); 6568 let order_draft = fs::read_to_string(order_file).expect("read order draft"); 6569 assert!(order_draft.contains("[buyer_actor]")); 6570 assert!(order_draft.contains("source = \"resolved_account\"")); 6571 assert!(order_draft.contains("[order.economics]")); 6572 assert!(order_draft.contains("pricing_basis = \"listing_event\"")); 6573 assert_eq!(quote["result"]["order"]["buyer_account_id"], account_id); 6574 assert_eq!( 6575 quote["result"]["order"]["buyer_actor_source"], 6576 "resolved_account" 6577 ); 6578 assert_eq!( 6579 quote["result"]["order"]["listing_event_id"], 6580 listing_event_id 6581 ); 6582 6583 let orders = sandbox.json_success(&["--format", "json", "order", "list"]); 6584 assert_eq!(orders["operation_id"], "order.list"); 6585 assert_eq!(orders["result"]["state"], "ready"); 6586 assert_eq!(orders["result"]["count"], 1); 6587 assert_eq!(orders["result"]["orders"][0]["id"], order_id); 6588 assert_eq!(orders["result"]["orders"][0]["ready_for_submit"], true); 6589 assert_eq!( 6590 orders["result"]["orders"][0]["listing_event_id"], 6591 listing_event_id 6592 ); 6593 assert_eq!( 6594 orders["result"]["orders"][0]["buyer_account_id"], 6595 account_id 6596 ); 6597 assert_eq!( 6598 orders["result"]["orders"][0]["buyer_actor_source"], 6599 "resolved_account" 6600 ); 6601 assert_eq!( 6602 orders["result"]["orders"][0]["economics"], 6603 quote_economics.clone() 6604 ); 6605 assert_eq!(orders["result"]["orders"][0]["issues"], Value::Null); 6606 assert_no_removed_command_reference(&orders, &["order", "list"]); 6607 assert_no_daemon_runtime_reference(&orders, &["order", "list"]); 6608 6609 let submit = 6610 sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); 6611 assert_eq!(submit["operation_id"], "order.submit"); 6612 assert_eq!(submit["dry_run"], true); 6613 assert_eq!(submit["result"]["state"], "dry_run"); 6614 assert_eq!(submit["result"]["source"], "SDK order submit ยท local key"); 6615 assert_eq!(submit["result"]["event_kind"], 3422); 6616 assert_eq!( 6617 submit["result"]["target_relays"][0], 6618 ORDERABLE_LISTING_RELAY 6619 ); 6620 assert_eq!( 6621 submit["result"]["event_id"] 6622 .as_str() 6623 .expect("event id") 6624 .len(), 6625 64 6626 ); 6627 assert_no_removed_command_reference(&submit, &["order", "submit", "--dry-run"]); 6628 assert_no_daemon_runtime_reference(&submit, &["order", "submit", "--dry-run"]); 6629 6630 let (output, unavailable_submit) = sandbox.json_output(&[ 6631 "--format", 6632 "json", 6633 "--approval-token", 6634 "approve", 6635 "order", 6636 "submit", 6637 order_id, 6638 ]); 6639 assert!(!output.status.success()); 6640 assert_eq!(output.status.code(), Some(3), "{unavailable_submit}"); 6641 assert_eq!(unavailable_submit["operation_id"], "order.submit"); 6642 assert_eq!(unavailable_submit["result"], Value::Null); 6643 assert_eq!( 6644 unavailable_submit["errors"][0]["code"], 6645 "operation_unavailable" 6646 ); 6647 assert_eq!( 6648 unavailable_submit["errors"][0]["detail"]["class"], 6649 "operation" 6650 ); 6651 assert_eq!( 6652 unavailable_submit["errors"][0]["detail"]["state"], 6653 "unavailable" 6654 ); 6655 assert!( 6656 unavailable_submit["errors"][0]["message"] 6657 .as_str() 6658 .expect("message") 6659 .contains("SDK relay publish") 6660 ); 6661 assert_no_removed_command_reference(&unavailable_submit, &["order", "submit"]); 6662 assert_no_daemon_runtime_reference(&unavailable_submit, &["order", "submit"]); 6663 6664 let order_after_submit = sandbox.json_success(&["--format", "json", "order", "get", order_id]); 6665 assert_eq!(order_after_submit["operation_id"], "order.get"); 6666 assert_eq!(order_after_submit["result"]["state"], "ready"); 6667 assert_eq!( 6668 order_after_submit["result"]["economics"], 6669 quote_economics.clone() 6670 ); 6671 assert_eq!(order_after_submit["result"]["job"], Value::Null); 6672 assert_eq!(order_after_submit["result"]["workflow"], Value::Null); 6673 assert_no_daemon_runtime_reference(&order_after_submit, &["order", "get"]); 6674 6675 let (watch_output, watch) = 6676 sandbox.json_output(&["--format", "json", "order", "event", "watch", order_id]); 6677 assert!(!watch_output.status.success()); 6678 assert_eq!(watch_output.status.code(), Some(3)); 6679 assert_eq!(watch["operation_id"], "order.event.watch"); 6680 assert_eq!(watch["result"], Value::Null); 6681 assert_eq!(watch["errors"][0]["code"], "not_implemented"); 6682 assert_eq!(watch["errors"][0]["detail"]["state"], "not_implemented"); 6683 assert_eq!(watch["errors"][0]["detail"]["order_id"], order_id); 6684 assert_eq!( 6685 watch["next_actions"][0]["command"], 6686 format!("radroots order status get {order_id}") 6687 ); 6688 assert_no_daemon_runtime_reference(&watch, &["order", "event", "watch"]); 6689 assert!( 6690 !serde_json::to_string(&watch) 6691 .expect("watch json") 6692 .contains("local order drafts") 6693 ); 6694 } 6695 6696 #[test] 6697 fn order_get_and_list_report_missing_bound_buyer_account() { 6698 let sandbox = RadrootsCliSandbox::new(); 6699 let order_id = create_ready_order(&sandbox, "missing_buyer_account"); 6700 6701 let ready = sandbox.json_success(&["--format", "json", "order", "get", order_id.as_str()]); 6702 let account_id = ready["result"]["buyer_account_id"] 6703 .as_str() 6704 .expect("buyer account id"); 6705 assert_eq!(ready["result"]["state"], "ready"); 6706 assert_eq!(ready["result"]["buyer_account_id"], account_id); 6707 assert_eq!(ready["result"]["buyer_custody"], "secret_backed"); 6708 assert_eq!(ready["result"]["buyer_write_capable"], true); 6709 6710 sandbox.json_success(&[ 6711 "--format", 6712 "json", 6713 "--approval-token", 6714 "approve", 6715 "account", 6716 "remove", 6717 account_id, 6718 ]); 6719 6720 let missing = sandbox.json_success(&["--format", "json", "order", "get", order_id.as_str()]); 6721 assert_eq!(missing["operation_id"], "order.get"); 6722 assert_eq!(missing["result"]["state"], "draft"); 6723 assert_eq!(missing["result"]["ready_for_submit"], false); 6724 assert_eq!(missing["result"]["buyer_account_id"], account_id); 6725 assert_eq!(missing["result"]["buyer_custody"], Value::Null); 6726 assert!( 6727 missing["result"]["issues"] 6728 .as_array() 6729 .expect("issues") 6730 .iter() 6731 .any(|issue| issue["code"] == "account_unresolved") 6732 ); 6733 assert!( 6734 missing["result"]["actions"] 6735 .as_array() 6736 .expect("actions") 6737 .iter() 6738 .any(|action| action == "radroots account import <path>") 6739 ); 6740 assert!( 6741 missing["result"]["actions"] 6742 .as_array() 6743 .expect("actions") 6744 .iter() 6745 .any(|action| action 6746 == &Value::String(format!("radroots order rebind {order_id} <selector>"))) 6747 ); 6748 6749 let list = sandbox.json_success(&["--format", "json", "order", "list"]); 6750 assert_eq!(list["result"]["state"], "degraded"); 6751 assert_eq!(list["result"]["orders"][0]["ready_for_submit"], false); 6752 assert!( 6753 list["result"]["orders"][0]["issues"] 6754 .as_array() 6755 .expect("issues") 6756 .iter() 6757 .any(|issue| issue["code"] == "account_unresolved") 6758 ); 6759 6760 let (submit_output, submit) = sandbox.json_output(&[ 6761 "--format", 6762 "json", 6763 "--dry-run", 6764 "order", 6765 "submit", 6766 order_id.as_str(), 6767 ]); 6768 assert!(!submit_output.status.success()); 6769 assert_eq!(submit_output.status.code(), Some(5)); 6770 assert_eq!(submit["operation_id"], "order.submit"); 6771 assert_eq!(submit["errors"][0]["code"], "account_unresolved"); 6772 assert_eq!(submit["errors"][0]["detail"]["order_id"], order_id); 6773 } 6774 6775 #[test] 6776 fn order_get_marks_watch_only_bound_buyer_unready() { 6777 let sandbox = RadrootsCliSandbox::new(); 6778 let public_identity = identity_public(92); 6779 let public_identity_file = 6780 write_public_identity_profile(&sandbox, "order-watch-only-buyer", &public_identity); 6781 let imported = sandbox.json_success(&[ 6782 "--format", 6783 "json", 6784 "--approval-token", 6785 "approve", 6786 "account", 6787 "import", 6788 "--default", 6789 public_identity_file.to_string_lossy().as_ref(), 6790 ]); 6791 let account_id = imported["result"]["account"]["id"] 6792 .as_str() 6793 .expect("watch account id"); 6794 assert_eq!(imported["result"]["account"]["custody"], "watch_only"); 6795 6796 seed_orderable_listing(&sandbox, LISTING_ADDR); 6797 sandbox.json_success(&["--format", "json", "basket", "create", "watch_buyer"]); 6798 sandbox.json_success(&[ 6799 "--format", 6800 "json", 6801 "basket", 6802 "item", 6803 "add", 6804 "watch_buyer", 6805 "--listing-addr", 6806 LISTING_ADDR, 6807 "--bin-id", 6808 "bin-1", 6809 "--quantity", 6810 "2", 6811 ]); 6812 let quote = sandbox.json_success(&[ 6813 "--format", 6814 "json", 6815 "basket", 6816 "quote", 6817 "create", 6818 "watch_buyer", 6819 ]); 6820 let order_id = quote["result"]["quote"]["order_id"] 6821 .as_str() 6822 .expect("order id"); 6823 6824 let get = sandbox.json_success(&["--format", "json", "order", "get", order_id]); 6825 assert_eq!(get["result"]["state"], "draft"); 6826 assert_eq!(get["result"]["ready_for_submit"], false); 6827 assert_eq!(get["result"]["buyer_account_id"], account_id); 6828 assert_eq!(get["result"]["buyer_custody"], "watch_only"); 6829 assert_eq!(get["result"]["buyer_write_capable"], false); 6830 assert!( 6831 get["result"]["issues"] 6832 .as_array() 6833 .expect("issues") 6834 .iter() 6835 .any(|issue| issue["code"] == "account_watch_only") 6836 ); 6837 assert!( 6838 get["result"]["actions"] 6839 .as_array() 6840 .expect("actions") 6841 .iter() 6842 .any(|action| action 6843 == &Value::String(format!( 6844 "radroots account attach-secret {account_id} <path>" 6845 ))) 6846 ); 6847 6848 let (submit_output, submit) = 6849 sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); 6850 assert!(!submit_output.status.success()); 6851 assert_eq!(submit_output.status.code(), Some(7)); 6852 assert_eq!(submit["operation_id"], "order.submit"); 6853 assert_eq!(submit["errors"][0]["code"], "account_watch_only"); 6854 assert_eq!( 6855 submit["errors"][0]["detail"]["order_buyer_account_id"], 6856 account_id 6857 ); 6858 } 6859 6860 #[test] 6861 fn order_get_marks_bound_buyer_pubkey_mismatch_unready() { 6862 let sandbox = RadrootsCliSandbox::new(); 6863 let order_id = create_ready_order(&sandbox, "mismatched_buyer_actor"); 6864 let other_pubkey = identity_public(93).public_key_hex; 6865 rewrite_order_buyer_actor_pubkey(&sandbox, order_id.as_str(), other_pubkey.as_str()); 6866 6867 let get = sandbox.json_success(&["--format", "json", "order", "get", order_id.as_str()]); 6868 assert_eq!(get["result"]["state"], "draft"); 6869 assert_eq!(get["result"]["ready_for_submit"], false); 6870 assert_eq!(get["result"]["buyer_custody"], "secret_backed"); 6871 assert_eq!(get["result"]["buyer_write_capable"], true); 6872 assert!( 6873 get["result"]["issues"] 6874 .as_array() 6875 .expect("issues") 6876 .iter() 6877 .any(|issue| issue["code"] == "account_mismatch") 6878 ); 6879 assert!( 6880 get["result"]["actions"] 6881 .as_array() 6882 .expect("actions") 6883 .iter() 6884 .any(|action| action 6885 == &Value::String(format!("radroots order rebind {order_id} <selector>"))) 6886 ); 6887 } 6888 6889 #[test] 6890 fn order_rebind_previews_and_writes_bound_buyer_actor_updates() { 6891 let sandbox = RadrootsCliSandbox::new(); 6892 let order_id = create_ready_order(&sandbox, "order_rebind"); 6893 let order_file = sandbox 6894 .root() 6895 .join("data/apps/cli/orders/drafts") 6896 .join(format!("{order_id}.toml")); 6897 let before = fs::read_to_string(&order_file).expect("order before rebind"); 6898 let second = sandbox.json_success(&["--format", "json", "account", "create"]); 6899 let second_account_id = second["result"]["account"]["id"] 6900 .as_str() 6901 .expect("second account id"); 6902 6903 let dry_run = sandbox.json_success(&[ 6904 "--format", 6905 "json", 6906 "--dry-run", 6907 "order", 6908 "rebind", 6909 order_id.as_str(), 6910 second_account_id, 6911 ]); 6912 assert_eq!(dry_run["operation_id"], "order.rebind"); 6913 assert_eq!(dry_run["result"]["state"], "dry_run"); 6914 assert_eq!(dry_run["result"]["from_order_id"], order_id); 6915 assert_eq!(dry_run["result"]["order_id_changed"], true); 6916 assert_eq!(dry_run["result"]["buyer_pubkey_changed"], true); 6917 assert_eq!(dry_run["result"]["to_buyer_account_id"], second_account_id); 6918 assert_eq!( 6919 dry_run["result"]["existing_request_check"], 6920 "skipped_no_relays" 6921 ); 6922 assert_eq!( 6923 fs::read_to_string(&order_file).expect("order after dry-run rebind"), 6924 before 6925 ); 6926 6927 let (unapproved_output, unapproved) = sandbox.json_output(&[ 6928 "--format", 6929 "json", 6930 "order", 6931 "rebind", 6932 order_id.as_str(), 6933 second_account_id, 6934 ]); 6935 assert!(!unapproved_output.status.success()); 6936 assert_eq!(unapproved["operation_id"], "order.rebind"); 6937 assert_eq!(unapproved["errors"][0]["code"], "approval_required"); 6938 6939 let rebound = sandbox.json_success(&[ 6940 "--format", 6941 "json", 6942 "--approval-token", 6943 "approve", 6944 "order", 6945 "rebind", 6946 order_id.as_str(), 6947 second_account_id, 6948 ]); 6949 assert_eq!(rebound["operation_id"], "order.rebind"); 6950 assert_eq!(rebound["result"]["state"], "rebound"); 6951 assert_eq!(rebound["result"]["from_order_id"], order_id); 6952 assert_eq!(rebound["result"]["order_id_changed"], true); 6953 let rebound_order_id = rebound["result"]["to_order_id"] 6954 .as_str() 6955 .expect("rebound order id"); 6956 assert_ne!(rebound_order_id, order_id); 6957 let rebound_file = rebound["result"]["file"].as_str().expect("rebound file"); 6958 assert!(!order_file.exists()); 6959 let after = fs::read_to_string(rebound_file).expect("order after rebind"); 6960 assert!(after.contains("[buyer_actor]")); 6961 assert!(after.contains("source = \"order_rebind\"")); 6962 assert!(after.contains(format!("order_id = \"{rebound_order_id}\"").as_str())); 6963 assert!(after.contains(format!("quote_id = \"quote_{rebound_order_id}\"").as_str())); 6964 6965 let get = sandbox.json_success(&["--format", "json", "order", "get", rebound_order_id]); 6966 assert_eq!(get["result"]["state"], "ready"); 6967 assert_eq!(get["result"]["buyer_account_id"], second_account_id); 6968 assert_eq!(get["result"]["buyer_actor_source"], "order_rebind"); 6969 6970 let same = sandbox.json_success(&[ 6971 "--format", 6972 "json", 6973 "--approval-token", 6974 "approve", 6975 "order", 6976 "rebind", 6977 rebound_order_id, 6978 second_account_id, 6979 ]); 6980 assert_eq!(same["result"]["state"], "rebound"); 6981 assert_eq!(same["result"]["order_id_changed"], false); 6982 assert_eq!(same["result"]["to_order_id"], rebound_order_id); 6983 } 6984 6985 #[test] 6986 fn order_rebind_refuses_visible_published_request() { 6987 let sandbox = RadrootsCliSandbox::new(); 6988 let buyer = identity_secret(94); 6989 let buyer_public = buyer.to_public(); 6990 let buyer_public_file = 6991 write_public_identity_profile(&sandbox, "rebind-visible-buyer", &buyer_public); 6992 sandbox.json_success(&[ 6993 "--format", 6994 "json", 6995 "--approval-token", 6996 "approve", 6997 "account", 6998 "import", 6999 "--default", 7000 buyer_public_file.to_string_lossy().as_ref(), 7001 ]); 7002 let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR); 7003 sandbox.json_success(&["--format", "json", "basket", "create", "visible_rebind"]); 7004 sandbox.json_success(&[ 7005 "--format", 7006 "json", 7007 "basket", 7008 "item", 7009 "add", 7010 "visible_rebind", 7011 "--listing-addr", 7012 LISTING_ADDR, 7013 "--bin-id", 7014 "bin-1", 7015 "--quantity", 7016 "2", 7017 ]); 7018 let quote = sandbox.json_success(&[ 7019 "--format", 7020 "json", 7021 "basket", 7022 "quote", 7023 "create", 7024 "visible_rebind", 7025 ]); 7026 let order_id = quote["result"]["quote"]["order_id"] 7027 .as_str() 7028 .expect("order id"); 7029 let economics: RadrootsOrderEconomics = 7030 serde_json::from_value(quote["result"]["quote"]["economics"].clone()) 7031 .expect("quote economics"); 7032 let event = signed_order_request_event_for_quote( 7033 &buyer, 7034 order_id, 7035 listing_event_id.as_str(), 7036 economics, 7037 ); 7038 let target = sandbox.json_success(&["--format", "json", "account", "create"]); 7039 let target_account_id = target["result"]["account"]["id"] 7040 .as_str() 7041 .expect("target account id"); 7042 let relay = RelayFetchServer::with_events(vec![event]); 7043 7044 let (output, value) = sandbox.json_output(&[ 7045 "--format", 7046 "json", 7047 "--dry-run", 7048 "--relay", 7049 relay.endpoint(), 7050 "order", 7051 "rebind", 7052 order_id, 7053 target_account_id, 7054 ]); 7055 relay.join(); 7056 7057 assert!(!output.status.success()); 7058 assert_eq!(output.status.code(), Some(10)); 7059 assert_eq!(value["operation_id"], "order.rebind"); 7060 assert_eq!(value["errors"][0]["code"], "validation_failed"); 7061 assert_eq!( 7062 value["errors"][0]["detail"]["existing_request_check"], 7063 "blocked_existing_request" 7064 ); 7065 assert_eq!( 7066 value["errors"][0]["detail"]["existing_request_event_ids"] 7067 .as_array() 7068 .expect("existing request ids") 7069 .len(), 7070 1 7071 ); 7072 } 7073 7074 #[test] 7075 fn order_status_and_event_list_use_draft_context_after_account_override_drift() { 7076 let sandbox = RadrootsCliSandbox::new(); 7077 let buyer = identity_secret(95); 7078 let buyer_public_file = 7079 write_public_identity_profile(&sandbox, "status-draft-buyer", &buyer.to_public()); 7080 sandbox.json_success(&[ 7081 "--format", 7082 "json", 7083 "--approval-token", 7084 "approve", 7085 "account", 7086 "import", 7087 "--default", 7088 buyer_public_file.to_string_lossy().as_ref(), 7089 ]); 7090 let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR); 7091 sandbox.json_success(&["--format", "json", "basket", "create", "draft_status"]); 7092 sandbox.json_success(&[ 7093 "--format", 7094 "json", 7095 "basket", 7096 "item", 7097 "add", 7098 "draft_status", 7099 "--listing-addr", 7100 LISTING_ADDR, 7101 "--bin-id", 7102 "bin-1", 7103 "--quantity", 7104 "2", 7105 ]); 7106 let quote = sandbox.json_success(&[ 7107 "--format", 7108 "json", 7109 "basket", 7110 "quote", 7111 "create", 7112 "draft_status", 7113 ]); 7114 let order_id = quote["result"]["quote"]["order_id"] 7115 .as_str() 7116 .expect("order id"); 7117 let economics: RadrootsOrderEconomics = 7118 serde_json::from_value(quote["result"]["quote"]["economics"].clone()) 7119 .expect("quote economics"); 7120 let event = signed_order_request_event_for_quote( 7121 &buyer, 7122 order_id, 7123 listing_event_id.as_str(), 7124 economics, 7125 ); 7126 let drift_account = sandbox.json_success(&["--format", "json", "account", "create"]); 7127 let drift_account_id = drift_account["result"]["account"]["id"] 7128 .as_str() 7129 .expect("drift account id"); 7130 7131 let status = sandbox.json_success(&[ 7132 "--format", 7133 "json", 7134 "--account-id", 7135 drift_account_id, 7136 "order", 7137 "status", 7138 "get", 7139 order_id, 7140 ]); 7141 7142 assert_eq!(status["operation_id"], "order.status.get"); 7143 assert_eq!(status["result"]["source"], "SDK local order projection"); 7144 assert_eq!( 7145 status["result"]["actor_context_source"], 7146 "sdk_local_projection" 7147 ); 7148 assert_eq!(status["result"]["state"], "missing"); 7149 assert_eq!(status["result"]["fetched_count"], 0); 7150 assert_eq!(status["result"]["decoded_count"], 0); 7151 7152 let event_list_relay = RelayFetchServer::with_events(vec![event]); 7153 let events = sandbox.json_success(&[ 7154 "--format", 7155 "json", 7156 "--account-id", 7157 drift_account_id, 7158 "--relay", 7159 event_list_relay.endpoint(), 7160 "order", 7161 "event", 7162 "list", 7163 order_id, 7164 ]); 7165 event_list_relay.join(); 7166 7167 assert_eq!(events["operation_id"], "order.event.list"); 7168 assert_eq!(events["result"]["actor_context_source"], "order_draft"); 7169 assert_eq!(events["result"]["seller_pubkey"], "1".repeat(64)); 7170 assert_eq!(events["result"]["count"], 1); 7171 assert_eq!(events["result"]["orders"][0]["id"], order_id); 7172 } 7173 7174 #[test] 7175 fn order_cancel_uses_bound_buyer_after_default_account_drift() { 7176 let sandbox = RadrootsCliSandbox::new(); 7177 let buyer = identity_secret(96); 7178 let buyer_public_file = 7179 write_public_identity_profile(&sandbox, "cancel-bound-buyer", &buyer.to_public()); 7180 let imported = sandbox.json_success(&[ 7181 "--format", 7182 "json", 7183 "--approval-token", 7184 "approve", 7185 "account", 7186 "import", 7187 "--default", 7188 buyer_public_file.to_string_lossy().as_ref(), 7189 ]); 7190 let buyer_account_id = imported["result"]["account"]["id"] 7191 .as_str() 7192 .expect("buyer account id"); 7193 let buyer_secret_file = write_secret_identity_profile(&sandbox, "cancel-bound-secret", &buyer); 7194 sandbox.json_success(&[ 7195 "--format", 7196 "json", 7197 "--approval-token", 7198 "approve", 7199 "account", 7200 "attach-secret", 7201 buyer_account_id, 7202 buyer_secret_file.to_string_lossy().as_ref(), 7203 "--default", 7204 ]); 7205 let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR); 7206 sandbox.json_success(&["--format", "json", "basket", "create", "bound_cancel"]); 7207 sandbox.json_success(&[ 7208 "--format", 7209 "json", 7210 "basket", 7211 "item", 7212 "add", 7213 "bound_cancel", 7214 "--listing-addr", 7215 LISTING_ADDR, 7216 "--bin-id", 7217 "bin-1", 7218 "--quantity", 7219 "2", 7220 ]); 7221 let quote = sandbox.json_success(&[ 7222 "--format", 7223 "json", 7224 "basket", 7225 "quote", 7226 "create", 7227 "bound_cancel", 7228 ]); 7229 let order_id = quote["result"]["quote"]["order_id"] 7230 .as_str() 7231 .expect("order id"); 7232 let economics: RadrootsOrderEconomics = 7233 serde_json::from_value(quote["result"]["quote"]["economics"].clone()) 7234 .expect("quote economics"); 7235 let event = signed_order_request_event_for_quote( 7236 &buyer, 7237 order_id, 7238 listing_event_id.as_str(), 7239 economics, 7240 ); 7241 let drift_account = sandbox.json_success(&["--format", "json", "account", "create"]); 7242 let drift_account_id = drift_account["result"]["account"]["id"] 7243 .as_str() 7244 .expect("drift account id"); 7245 sandbox.json_success(&[ 7246 "--format", 7247 "json", 7248 "account", 7249 "selection", 7250 "update", 7251 drift_account_id, 7252 ]); 7253 let relay = RelayFetchServer::with_events(vec![event]); 7254 7255 let cancel = sandbox.json_success(&[ 7256 "--format", 7257 "json", 7258 "--dry-run", 7259 "--relay", 7260 relay.endpoint(), 7261 "order", 7262 "cancel", 7263 order_id, 7264 "--reason", 7265 "changed plans", 7266 ]); 7267 relay.join(); 7268 7269 assert_eq!(cancel["operation_id"], "order.cancel"); 7270 assert_eq!(cancel["result"]["state"], "dry_run"); 7271 assert_eq!(cancel["result"]["buyer_pubkey"], buyer.public_key_hex()); 7272 assert_eq!(cancel["result"]["signer_mode"], "local"); 7273 } 7274 7275 #[test] 7276 fn buyer_side_order_writes_reject_conflicting_account_override_for_local_draft() { 7277 let sandbox = RadrootsCliSandbox::new(); 7278 let order_id = create_ready_order(&sandbox, "buyer_write_drift"); 7279 let drift_account = sandbox.json_success(&["--format", "json", "account", "create"]); 7280 let drift_account_id = drift_account["result"]["account"]["id"] 7281 .as_str() 7282 .expect("drift account id"); 7283 7284 for (operation_id, command) in [ 7285 ( 7286 "order.revision.accept", 7287 vec![ 7288 "--format", 7289 "json", 7290 "--dry-run", 7291 "--account-id", 7292 drift_account_id, 7293 "--relay", 7294 "ws://127.0.0.1:9", 7295 "order", 7296 "revision", 7297 "accept", 7298 order_id.as_str(), 7299 "--revision-id", 7300 "rev_pending", 7301 ], 7302 ), 7303 ( 7304 "order.cancel", 7305 vec![ 7306 "--format", 7307 "json", 7308 "--dry-run", 7309 "--account-id", 7310 drift_account_id, 7311 "--relay", 7312 "ws://127.0.0.1:9", 7313 "order", 7314 "cancel", 7315 order_id.as_str(), 7316 "--reason", 7317 "changed plans", 7318 ], 7319 ), 7320 ] { 7321 let (output, value) = sandbox.json_output(command.as_slice()); 7322 7323 assert!(!output.status.success(), "{operation_id} should fail"); 7324 assert_eq!(output.status.code(), Some(5)); 7325 assert_eq!(value["operation_id"], operation_id); 7326 assert_eq!(value["result"], Value::Null); 7327 assert_eq!(value["errors"][0]["code"], "account_mismatch"); 7328 assert_eq!(value["errors"][0]["detail"]["order_id"], order_id); 7329 assert_eq!( 7330 value["errors"][0]["detail"]["attempted_buyer_account_id"], 7331 drift_account_id 7332 ); 7333 } 7334 } 7335 7336 #[test] 7337 fn order_submit_requires_local_replica_freshness_before_signing() { 7338 let sandbox = RadrootsCliSandbox::new(); 7339 let order_id = create_ready_order(&sandbox, "freshness_missing_db"); 7340 fs::remove_file(sandbox.replica_db_path()).expect("remove replica db"); 7341 7342 let (output, value) = sandbox.json_output(&[ 7343 "--format", 7344 "json", 7345 "--relay", 7346 "ws://127.0.0.1:9", 7347 "--approval-token", 7348 "approve", 7349 "order", 7350 "submit", 7351 order_id.as_str(), 7352 ]); 7353 7354 assert!(!output.status.success()); 7355 assert_eq!(output.status.code(), Some(3)); 7356 assert_eq!(value["operation_id"], "order.submit"); 7357 assert_eq!(value["errors"][0]["code"], "operation_unavailable"); 7358 assert_eq!(value["errors"][0]["detail"]["state"], "unconfigured"); 7359 assert_eq!( 7360 value["errors"][0]["detail"]["issues"][0]["field"], 7361 "order.listing_addr" 7362 ); 7363 assert!( 7364 value["errors"][0]["message"] 7365 .as_str() 7366 .expect("message") 7367 .contains("run `radroots store init` and `radroots market refresh`") 7368 ); 7369 } 7370 7371 #[test] 7372 fn order_submit_dry_run_requires_local_replica_freshness() { 7373 let sandbox = RadrootsCliSandbox::new(); 7374 let order_id = create_ready_order(&sandbox, "dry_freshness_missing_db"); 7375 fs::remove_file(sandbox.replica_db_path()).expect("remove replica db"); 7376 7377 let (output, value) = sandbox.json_output(&[ 7378 "--format", 7379 "json", 7380 "--dry-run", 7381 "order", 7382 "submit", 7383 order_id.as_str(), 7384 ]); 7385 7386 assert!(!output.status.success()); 7387 assert_eq!(output.status.code(), Some(3)); 7388 assert_eq!(value["operation_id"], "order.submit"); 7389 assert_eq!(value["dry_run"], true); 7390 assert_eq!(value["errors"][0]["code"], "operation_unavailable"); 7391 assert_eq!(value["errors"][0]["detail"]["state"], "unconfigured"); 7392 assert_eq!( 7393 value["errors"][0]["detail"]["issues"][0]["field"], 7394 "order.listing_addr" 7395 ); 7396 } 7397 7398 #[test] 7399 fn order_submit_rejects_missing_or_archived_local_listing_before_publish() { 7400 let sandbox = RadrootsCliSandbox::new(); 7401 let order_id = create_ready_order(&sandbox, "freshness_missing_listing"); 7402 remove_orderable_listing(&sandbox, LISTING_ADDR); 7403 7404 let (output, value) = sandbox.json_output(&[ 7405 "--format", 7406 "json", 7407 "--relay", 7408 "ws://127.0.0.1:9", 7409 "--approval-token", 7410 "approve", 7411 "order", 7412 "submit", 7413 order_id.as_str(), 7414 ]); 7415 7416 assert!(!output.status.success()); 7417 assert_eq!(output.status.code(), Some(3)); 7418 assert_eq!(value["operation_id"], "order.submit"); 7419 assert_eq!(value["errors"][0]["code"], "operation_unavailable"); 7420 assert_eq!( 7421 value["errors"][0]["detail"]["issues"][0]["field"], 7422 "order.listing_addr" 7423 ); 7424 assert!( 7425 value["errors"][0]["message"] 7426 .as_str() 7427 .expect("message") 7428 .contains("listing is not active") 7429 ); 7430 } 7431 7432 #[test] 7433 fn order_submit_rejects_superseded_local_listing_event_before_publish() { 7434 let sandbox = RadrootsCliSandbox::new(); 7435 let order_id = create_ready_order(&sandbox, "freshness_superseded_listing"); 7436 let replacement_event_id = "3".repeat(64); 7437 replace_latest_listing_event_id(&sandbox, LISTING_ADDR, replacement_event_id.as_str()); 7438 7439 let (output, value) = sandbox.json_output(&[ 7440 "--format", 7441 "json", 7442 "--relay", 7443 "ws://127.0.0.1:9", 7444 "--approval-token", 7445 "approve", 7446 "order", 7447 "submit", 7448 order_id.as_str(), 7449 ]); 7450 7451 assert!(!output.status.success()); 7452 assert_eq!(output.status.code(), Some(3)); 7453 assert_eq!(value["operation_id"], "order.submit"); 7454 assert_eq!(value["errors"][0]["code"], "operation_unavailable"); 7455 assert_eq!( 7456 value["errors"][0]["detail"]["issues"][0]["field"], 7457 "order.listing_event_id" 7458 ); 7459 assert!( 7460 value["errors"][0]["detail"]["issues"][0]["message"] 7461 .as_str() 7462 .expect("issue message") 7463 .contains(replacement_event_id.as_str()) 7464 ); 7465 } 7466 7467 #[test] 7468 fn order_submit_rejects_over_available_quantity_before_publish() { 7469 let sandbox = RadrootsCliSandbox::new(); 7470 sandbox.json_success(&["--format", "json", "account", "create"]); 7471 seed_orderable_listing(&sandbox, LISTING_ADDR); 7472 sandbox.json_success(&["--format", "json", "basket", "create", "over_quantity"]); 7473 sandbox.json_success(&[ 7474 "--format", 7475 "json", 7476 "basket", 7477 "item", 7478 "add", 7479 "over_quantity", 7480 "--listing-addr", 7481 LISTING_ADDR, 7482 "--bin-id", 7483 "bin-1", 7484 "--quantity", 7485 "6", 7486 ]); 7487 let quote = sandbox.json_success(&[ 7488 "--format", 7489 "json", 7490 "basket", 7491 "quote", 7492 "create", 7493 "over_quantity", 7494 ]); 7495 let order_id = quote["result"]["quote"]["order_id"] 7496 .as_str() 7497 .expect("order id"); 7498 7499 let (output, value) = sandbox.json_output(&[ 7500 "--format", 7501 "json", 7502 "--relay", 7503 "ws://127.0.0.1:9", 7504 "--approval-token", 7505 "approve", 7506 "order", 7507 "submit", 7508 order_id, 7509 ]); 7510 7511 assert!(!output.status.success()); 7512 assert_eq!(output.status.code(), Some(10)); 7513 assert_eq!(value["operation_id"], "order.submit"); 7514 assert_eq!(value["errors"][0]["code"], "validation_failed"); 7515 assert_eq!( 7516 value["errors"][0]["detail"]["issues"][0]["code"], 7517 "order_quantity_exceeds_available" 7518 ); 7519 assert!( 7520 value["errors"][0]["detail"]["issues"][0]["message"] 7521 .as_str() 7522 .expect("issue message") 7523 .contains("available quantity 5") 7524 ); 7525 assert_no_removed_command_reference(&value, &["order", "submit"]); 7526 assert_no_daemon_runtime_reference(&value, &["order", "submit"]); 7527 } 7528 7529 #[test] 7530 fn order_submit_rejects_unknown_local_listing_bin_before_publish() { 7531 let sandbox = RadrootsCliSandbox::new(); 7532 let order_id = create_ready_order(&sandbox, "unknown_bin"); 7533 rewrite_order_bin(&sandbox, order_id.as_str(), "unknown-bin"); 7534 7535 let (output, value) = sandbox.json_output(&[ 7536 "--format", 7537 "json", 7538 "--relay", 7539 "ws://127.0.0.1:9", 7540 "--approval-token", 7541 "approve", 7542 "order", 7543 "submit", 7544 order_id.as_str(), 7545 ]); 7546 7547 assert!(!output.status.success()); 7548 assert_eq!(output.status.code(), Some(10)); 7549 assert_eq!(value["operation_id"], "order.submit"); 7550 assert_eq!(value["errors"][0]["code"], "validation_failed"); 7551 assert_eq!( 7552 value["errors"][0]["detail"]["issues"][0]["code"], 7553 "order_bin_unknown" 7554 ); 7555 assert_eq!( 7556 value["errors"][0]["detail"]["issues"][0]["field"], 7557 "order.items[0].bin_id" 7558 ); 7559 assert!( 7560 value["errors"][0]["detail"]["issues"][0]["message"] 7561 .as_str() 7562 .expect("issue message") 7563 .contains("expected primary bin `bin-1`") 7564 ); 7565 assert_no_removed_command_reference(&value, &["order", "submit"]); 7566 assert_no_daemon_runtime_reference(&value, &["order", "submit"]); 7567 } 7568 7569 #[test] 7570 fn basket_quote_rejects_missing_replica_before_order_write() { 7571 let sandbox = RadrootsCliSandbox::new(); 7572 sandbox.json_success(&["--format", "json", "account", "create"]); 7573 sandbox.json_success(&["--format", "json", "basket", "create", "missing_replica"]); 7574 let add = sandbox.json_success(&[ 7575 "--format", 7576 "json", 7577 "basket", 7578 "item", 7579 "add", 7580 "missing_replica", 7581 "--listing-addr", 7582 LISTING_ADDR, 7583 "--bin-id", 7584 "bin-1", 7585 "--quantity", 7586 "2", 7587 ]); 7588 assert_eq!(add["result"]["ready_for_quote"], false); 7589 assert_eq!( 7590 add["result"]["issues"][0]["code"], 7591 "basket_market_replica_missing" 7592 ); 7593 7594 let quote = sandbox.json_success(&[ 7595 "--format", 7596 "json", 7597 "basket", 7598 "quote", 7599 "create", 7600 "missing_replica", 7601 ]); 7602 assert_eq!(quote["result"]["state"], "unconfigured"); 7603 assert_eq!(quote["result"]["ready_for_quote"], false); 7604 assert_eq!( 7605 quote["result"]["issues"][0]["code"], 7606 "basket_market_replica_missing" 7607 ); 7608 assert!(!sandbox.root().join("data/apps/cli/orders/drafts").exists()); 7609 } 7610 7611 #[test] 7612 fn basket_quote_rejects_unresolved_listing_before_order_write() { 7613 let sandbox = RadrootsCliSandbox::new(); 7614 sandbox.json_success(&["--format", "json", "account", "create"]); 7615 sandbox.json_success(&["--format", "json", "store", "init"]); 7616 sandbox.json_success(&["--format", "json", "basket", "create", "unresolved_listing"]); 7617 let add = sandbox.json_success(&[ 7618 "--format", 7619 "json", 7620 "basket", 7621 "item", 7622 "add", 7623 "unresolved_listing", 7624 "--listing-addr", 7625 LISTING_ADDR, 7626 "--bin-id", 7627 "bin-1", 7628 "--quantity", 7629 "2", 7630 ]); 7631 assert_eq!(add["result"]["ready_for_quote"], false); 7632 assert_eq!( 7633 add["result"]["issues"][0]["code"], 7634 "basket_item_listing_unresolved" 7635 ); 7636 7637 let quote = sandbox.json_success(&[ 7638 "--format", 7639 "json", 7640 "basket", 7641 "quote", 7642 "create", 7643 "unresolved_listing", 7644 ]); 7645 assert_eq!(quote["result"]["state"], "unconfigured"); 7646 assert_eq!(quote["result"]["ready_for_quote"], false); 7647 assert_eq!( 7648 quote["result"]["issues"][0]["code"], 7649 "basket_item_listing_unresolved" 7650 ); 7651 assert!(!sandbox.root().join("data/apps/cli/orders/drafts").exists()); 7652 } 7653 7654 #[test] 7655 fn basket_quote_rejects_ambiguous_listing_before_order_write() { 7656 let sandbox = RadrootsCliSandbox::new(); 7657 sandbox.json_success(&["--format", "json", "account", "create"]); 7658 seed_orderable_listing(&sandbox, LISTING_ADDR); 7659 duplicate_orderable_listing_row(&sandbox, LISTING_ADDR); 7660 sandbox.json_success(&["--format", "json", "basket", "create", "ambiguous_listing"]); 7661 let add = sandbox.json_success(&[ 7662 "--format", 7663 "json", 7664 "basket", 7665 "item", 7666 "add", 7667 "ambiguous_listing", 7668 "--listing-addr", 7669 LISTING_ADDR, 7670 "--bin-id", 7671 "bin-1", 7672 "--quantity", 7673 "2", 7674 ]); 7675 assert_eq!(add["result"]["ready_for_quote"], false); 7676 assert_eq!( 7677 add["result"]["issues"][0]["code"], 7678 "basket_item_listing_ambiguous" 7679 ); 7680 7681 let quote = sandbox.json_success(&[ 7682 "--format", 7683 "json", 7684 "basket", 7685 "quote", 7686 "create", 7687 "ambiguous_listing", 7688 ]); 7689 assert_eq!(quote["result"]["state"], "unconfigured"); 7690 assert_eq!(quote["result"]["ready_for_quote"], false); 7691 assert_eq!( 7692 quote["result"]["issues"][0]["code"], 7693 "basket_item_listing_ambiguous" 7694 ); 7695 assert!(!sandbox.root().join("data/apps/cli/orders/drafts").exists()); 7696 } 7697 7698 #[test] 7699 fn basket_quote_rejects_invalid_verified_primary_bin_before_order_write() { 7700 let sandbox = RadrootsCliSandbox::new(); 7701 sandbox.json_success(&["--format", "json", "account", "create"]); 7702 seed_orderable_listing(&sandbox, LISTING_ADDR); 7703 update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("missing-bin")); 7704 sandbox.json_success(&[ 7705 "--format", 7706 "json", 7707 "basket", 7708 "create", 7709 "invalid_primary_bin", 7710 ]); 7711 let add = sandbox.json_success(&[ 7712 "--format", 7713 "json", 7714 "basket", 7715 "item", 7716 "add", 7717 "invalid_primary_bin", 7718 "--listing-addr", 7719 LISTING_ADDR, 7720 "--bin-id", 7721 "bin-1", 7722 "--quantity", 7723 "2", 7724 ]); 7725 assert_eq!(add["result"]["ready_for_quote"], false); 7726 assert_eq!( 7727 add["result"]["issues"][0]["code"], 7728 "listing_primary_bin_invalid" 7729 ); 7730 7731 let validate = sandbox.json_success(&[ 7732 "--format", 7733 "json", 7734 "basket", 7735 "validate", 7736 "invalid_primary_bin", 7737 ]); 7738 assert_eq!(validate["result"]["state"], "unconfigured"); 7739 assert_eq!(validate["result"]["ready_for_quote"], false); 7740 assert_eq!( 7741 validate["result"]["issues"][0]["code"], 7742 "listing_primary_bin_invalid" 7743 ); 7744 7745 let quote = sandbox.json_success(&[ 7746 "--format", 7747 "json", 7748 "basket", 7749 "quote", 7750 "create", 7751 "invalid_primary_bin", 7752 ]); 7753 assert_eq!(quote["result"]["state"], "unconfigured"); 7754 assert_eq!(quote["result"]["ready_for_quote"], false); 7755 assert_eq!( 7756 quote["result"]["issues"][0]["code"], 7757 "listing_primary_bin_invalid" 7758 ); 7759 assert!(!sandbox.root().join("data/apps/cli/orders/drafts").exists()); 7760 } 7761 7762 #[test] 7763 fn order_submit_rejects_stale_invalid_verified_primary_bin_before_relay_preflight() { 7764 let sandbox = RadrootsCliSandbox::new(); 7765 let order_id = create_ready_order(&sandbox, "stale_invalid_bin"); 7766 update_orderable_listing_primary_bin_id(&sandbox, LISTING_ADDR, Some("missing-bin")); 7767 7768 let (output, value) = sandbox.json_output(&[ 7769 "--format", 7770 "json", 7771 "--dry-run", 7772 "order", 7773 "submit", 7774 &order_id, 7775 ]); 7776 7777 assert!(!output.status.success()); 7778 assert_eq!(output.status.code(), Some(10)); 7779 assert_eq!(value["operation_id"], "order.submit"); 7780 assert_eq!(value["dry_run"], true); 7781 assert_eq!(value["errors"][0]["code"], "validation_failed"); 7782 assert_eq!( 7783 value["errors"][0]["detail"]["issues"][0]["code"], 7784 "listing_primary_bin_invalid" 7785 ); 7786 assert_eq!( 7787 value["errors"][0]["detail"]["issues"][0]["field"], 7788 "inventory.primary_bin_id" 7789 ); 7790 assert_no_removed_command_reference(&value, &["order", "submit", "--dry-run"]); 7791 assert_no_daemon_runtime_reference(&value, &["order", "submit", "--dry-run"]); 7792 } 7793 7794 #[test] 7795 fn order_submit_dry_run_rejects_over_available_quantity_before_relay_preflight() { 7796 let sandbox = RadrootsCliSandbox::new(); 7797 sandbox.json_success(&["--format", "json", "account", "create"]); 7798 seed_orderable_listing(&sandbox, LISTING_ADDR); 7799 sandbox.json_success(&["--format", "json", "basket", "create", "dry_over_quantity"]); 7800 sandbox.json_success(&[ 7801 "--format", 7802 "json", 7803 "basket", 7804 "item", 7805 "add", 7806 "dry_over_quantity", 7807 "--listing-addr", 7808 LISTING_ADDR, 7809 "--bin-id", 7810 "bin-1", 7811 "--quantity", 7812 "6", 7813 ]); 7814 let quote = sandbox.json_success(&[ 7815 "--format", 7816 "json", 7817 "basket", 7818 "quote", 7819 "create", 7820 "dry_over_quantity", 7821 ]); 7822 let order_id = quote["result"]["quote"]["order_id"] 7823 .as_str() 7824 .expect("order id"); 7825 7826 let (output, value) = 7827 sandbox.json_output(&["--format", "json", "--dry-run", "order", "submit", order_id]); 7828 7829 assert!(!output.status.success()); 7830 assert_eq!(output.status.code(), Some(10)); 7831 assert_eq!(value["operation_id"], "order.submit"); 7832 assert_eq!(value["dry_run"], true); 7833 assert_eq!(value["errors"][0]["code"], "validation_failed"); 7834 assert_eq!( 7835 value["errors"][0]["detail"]["issues"][0]["code"], 7836 "order_quantity_exceeds_available" 7837 ); 7838 } 7839 7840 #[test] 7841 fn ready_order_submit_dry_run_validates_local_buyer_authority() { 7842 let sandbox = RadrootsCliSandbox::new(); 7843 let first = sandbox.json_success(&["--format", "json", "account", "create"]); 7844 let first_account_id = first["result"]["account"]["id"] 7845 .as_str() 7846 .expect("first account id"); 7847 let listing_event_id = seed_orderable_listing(&sandbox, LISTING_ADDR); 7848 sandbox.json_success(&["--format", "json", "basket", "create", "ready_order"]); 7849 sandbox.json_success(&[ 7850 "--format", 7851 "json", 7852 "basket", 7853 "item", 7854 "add", 7855 "ready_order", 7856 "--listing-addr", 7857 LISTING_ADDR, 7858 "--bin-id", 7859 "bin-1", 7860 "--quantity", 7861 "2", 7862 ]); 7863 let quote = sandbox.json_success(&[ 7864 "--format", 7865 "json", 7866 "basket", 7867 "quote", 7868 "create", 7869 "ready_order", 7870 ]); 7871 let order_id = quote["result"]["quote"]["order_id"] 7872 .as_str() 7873 .expect("order id"); 7874 assert_eq!(quote["result"]["quote"]["ready_for_submit"], true); 7875 assert_eq!( 7876 quote["result"]["order"]["buyer_account_id"], 7877 first_account_id 7878 ); 7879 assert_eq!( 7880 quote["result"]["order"]["listing_event_id"], 7881 listing_event_id 7882 ); 7883 7884 let dry_run = 7885 sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); 7886 assert_eq!(dry_run["operation_id"], "order.submit"); 7887 assert_eq!(dry_run["dry_run"], true); 7888 assert_eq!(dry_run["result"]["state"], "dry_run"); 7889 assert_eq!(dry_run["result"]["source"], "SDK order submit ยท local key"); 7890 assert_eq!(dry_run["result"]["event_kind"], 3422); 7891 assert_eq!( 7892 dry_run["result"]["target_relays"][0], 7893 ORDERABLE_LISTING_RELAY 7894 ); 7895 assert_no_daemon_runtime_reference(&dry_run, &["order", "submit", "--dry-run"]); 7896 7897 let second = sandbox.json_success(&["--format", "json", "account", "create"]); 7898 let second_account_id = second["result"]["account"]["id"] 7899 .as_str() 7900 .expect("second account id"); 7901 sandbox.json_success(&[ 7902 "--format", 7903 "json", 7904 "account", 7905 "selection", 7906 "update", 7907 second_account_id, 7908 ]); 7909 let drift = 7910 sandbox.json_success(&["--format", "json", "--dry-run", "order", "submit", order_id]); 7911 assert_eq!(drift["operation_id"], "order.submit"); 7912 assert_eq!(drift["result"]["state"], "dry_run"); 7913 assert_eq!(drift["result"]["buyer_account_id"], first_account_id); 7914 7915 let (output, mismatch) = sandbox.json_output(&[ 7916 "--format", 7917 "json", 7918 "--account-id", 7919 second_account_id, 7920 "--dry-run", 7921 "order", 7922 "submit", 7923 order_id, 7924 ]); 7925 7926 assert!(!output.status.success()); 7927 assert_eq!(output.status.code(), Some(5)); 7928 assert_eq!(mismatch["operation_id"], "order.submit"); 7929 assert_eq!(mismatch["errors"][0]["code"], "account_mismatch"); 7930 assert_eq!(mismatch["errors"][0]["detail"]["class"], "account"); 7931 assert_no_removed_command_reference(&mismatch, &["order", "submit", "--dry-run"]); 7932 assert_no_daemon_runtime_reference(&mismatch, &["order", "submit", "--dry-run"]); 7933 7934 let (network_output, network_mismatch) = sandbox.json_output(&[ 7935 "--format", 7936 "json", 7937 "--account-id", 7938 second_account_id, 7939 "--relay", 7940 "ws://127.0.0.1:9", 7941 "--approval-token", 7942 "approve", 7943 "order", 7944 "submit", 7945 order_id, 7946 ]); 7947 7948 assert!(!network_output.status.success()); 7949 assert_eq!(network_output.status.code(), Some(5)); 7950 assert_eq!(network_mismatch["operation_id"], "order.submit"); 7951 assert_eq!(network_mismatch["result"], Value::Null); 7952 assert_eq!(network_mismatch["errors"][0]["code"], "account_mismatch"); 7953 assert_eq!(network_mismatch["errors"][0]["detail"]["class"], "account"); 7954 assert_no_daemon_runtime_reference(&network_mismatch, &["order", "submit"]); 7955 } 7956 7957 #[test] 7958 fn seller_target_flow_acceptance_uses_target_operations() { 7959 let sandbox = RadrootsCliSandbox::new(); 7960 7961 let account = sandbox.json_success(&["--format", "json", "account", "create"]); 7962 let account_id = account["result"]["account"]["id"] 7963 .as_str() 7964 .expect("account id"); 7965 assert_eq!(account["operation_id"], "account.create"); 7966 assert_eq!(account["result"]["account"]["signer"], "local"); 7967 assert_eq!(account["result"]["account"]["custody"], "secret_backed"); 7968 assert_eq!(account["result"]["account"]["write_capable"], true); 7969 assert_no_removed_command_reference(&account, &["account", "create"]); 7970 7971 let signer = sandbox.json_success(&["--format", "json", "signer", "status", "get"]); 7972 assert_eq!(signer["operation_id"], "signer.status.get"); 7973 assert_eq!(signer["result"]["mode"], "local"); 7974 assert_eq!(signer["result"]["state"], "ready"); 7975 assert_eq!(signer["result"]["signer_account_id"], account_id); 7976 assert_no_removed_command_reference(&signer, &["signer", "status", "get"]); 7977 7978 let farm = sandbox.json_success(&[ 7979 "--format", 7980 "json", 7981 "farm", 7982 "create", 7983 "--name", 7984 "Green Farm", 7985 "--location", 7986 "farmstand", 7987 "--country", 7988 "US", 7989 "--delivery-method", 7990 "pickup", 7991 ]); 7992 assert_eq!(farm["operation_id"], "farm.create"); 7993 assert_eq!(farm["result"]["state"], "saved"); 7994 assert_no_removed_command_reference(&farm, &["farm", "create"]); 7995 7996 let create = sandbox.json_success(&[ 7997 "--format", 7998 "json", 7999 "listing", 8000 "create", 8001 "--key", 8002 "eggs", 8003 "--title", 8004 "Eggs", 8005 "--category", 8006 "eggs", 8007 "--summary", 8008 "Fresh eggs", 8009 "--bin-id", 8010 "bin-1", 8011 "--quantity-amount", 8012 "1", 8013 "--quantity-unit", 8014 "each", 8015 "--price-amount", 8016 "6", 8017 "--price-currency", 8018 "USD", 8019 "--price-per-amount", 8020 "1", 8021 "--price-per-unit", 8022 "each", 8023 "--available", 8024 "10", 8025 ]); 8026 let listing_file = create["result"]["file"].as_str().expect("listing file"); 8027 assert_eq!(create["operation_id"], "listing.create"); 8028 assert!(Path::new(listing_file).exists()); 8029 assert_no_removed_command_reference(&create, &["listing", "create"]); 8030 8031 let list = sandbox.json_success(&["--format", "json", "listing", "list"]); 8032 assert_eq!(list["operation_id"], "listing.list"); 8033 assert_eq!(list["result"]["state"], "ready"); 8034 assert_eq!(list["result"]["count"], 1); 8035 assert_eq!( 8036 list["result"]["listings"][0]["id"], 8037 create["result"]["listing_id"] 8038 ); 8039 assert_eq!(list["result"]["listings"][0]["state"], "ready"); 8040 assert_no_removed_command_reference(&list, &["listing", "list"]); 8041 8042 let validate = sandbox.json_success(&["--format", "json", "listing", "validate", listing_file]); 8043 assert_eq!(validate["operation_id"], "listing.validate"); 8044 assert_eq!(validate["result"]["valid"], true); 8045 assert_eq!(validate["result"]["issues"], Value::Null); 8046 assert_no_removed_command_reference(&validate, &["listing", "validate"]); 8047 8048 let publish = sandbox.json_success(&[ 8049 "--format", 8050 "json", 8051 "--dry-run", 8052 "listing", 8053 "publish", 8054 listing_file, 8055 ]); 8056 assert_eq!(publish["operation_id"], "listing.publish"); 8057 assert_eq!(publish["result"]["state"], "dry_run"); 8058 assert_no_removed_command_reference(&publish, &["listing", "publish", "--dry-run"]); 8059 assert_no_daemon_runtime_reference(&publish, &["listing", "publish", "--dry-run"]); 8060 8061 let archive = sandbox.json_success(&[ 8062 "--format", 8063 "json", 8064 "--dry-run", 8065 "listing", 8066 "archive", 8067 listing_file, 8068 ]); 8069 assert_eq!(archive["operation_id"], "listing.archive"); 8070 assert_eq!(archive["result"]["state"], "dry_run"); 8071 assert_eq!(archive["result"]["operation"], "archive"); 8072 assert_no_removed_command_reference(&archive, &["listing", "archive", "--dry-run"]); 8073 assert_no_daemon_runtime_reference(&archive, &["listing", "archive", "--dry-run"]); 8074 8075 let (publish_output, unavailable_publish) = sandbox.json_output(&[ 8076 "--format", 8077 "json", 8078 "--approval-token", 8079 "approve", 8080 "listing", 8081 "publish", 8082 listing_file, 8083 ]); 8084 assert!(!publish_output.status.success()); 8085 assert_eq!(unavailable_publish["operation_id"], "listing.publish"); 8086 assert_eq!( 8087 unavailable_publish["errors"][0]["code"], 8088 "empty_target_relays" 8089 ); 8090 assert_eq!( 8091 unavailable_publish["errors"][0]["detail"]["class"], 8092 "configuration" 8093 ); 8094 assert_no_removed_command_reference(&unavailable_publish, &["listing", "publish"]); 8095 assert_no_daemon_runtime_reference(&unavailable_publish, &["listing", "publish"]); 8096 8097 let (archive_output, unavailable_archive) = sandbox.json_output(&[ 8098 "--format", 8099 "json", 8100 "--approval-token", 8101 "approve", 8102 "listing", 8103 "archive", 8104 listing_file, 8105 ]); 8106 assert!(!archive_output.status.success()); 8107 assert_eq!(unavailable_archive["operation_id"], "listing.archive"); 8108 assert_eq!( 8109 unavailable_archive["errors"][0]["code"], 8110 "empty_target_relays" 8111 ); 8112 assert_eq!( 8113 unavailable_archive["errors"][0]["detail"]["class"], 8114 "configuration" 8115 ); 8116 assert_no_removed_command_reference(&unavailable_archive, &["listing", "archive"]); 8117 assert_no_daemon_runtime_reference(&unavailable_archive, &["listing", "archive"]); 8118 }