discovery_cli.rs (51876B)
1 use std::collections::{HashMap, VecDeque}; 2 use std::fs; 3 use std::net::TcpListener as StdTcpListener; 4 use std::path::Path; 5 use std::process::{Command, Output}; 6 use std::sync::Arc; 7 use std::time::Duration; 8 9 use futures_util::{SinkExt, StreamExt}; 10 use nostr::filter::MatchEventOptions; 11 use nostr::{ClientMessage, Event, Filter, JsonUtil, PublicKey, RelayMessage, SubscriptionId}; 12 use radroots_identity::RadrootsIdentity; 13 use radroots_nostr::prelude::{ 14 RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrMetadata, 15 radroots_nostr_build_application_handler_event, 16 }; 17 use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri}; 18 use serde_json::Value; 19 use tokio::net::{TcpListener, TcpStream}; 20 use tokio::sync::{Mutex, Notify, mpsc, oneshot}; 21 use tokio::time::timeout; 22 use tokio_tungstenite::tungstenite::Message; 23 24 type TestResult<T> = Result<T, Box<dyn std::error::Error + Send + Sync>>; 25 26 const RELAY_EVENT_TIMEOUT: Duration = Duration::from_secs(15); 27 28 #[derive(Clone)] 29 struct RelaySubscription { 30 connection_id: usize, 31 subscription_id: SubscriptionId, 32 filters: Vec<Filter>, 33 } 34 35 #[derive(Default)] 36 struct RelayState { 37 next_connection_id: usize, 38 senders: HashMap<usize, mpsc::UnboundedSender<Message>>, 39 subscriptions: Vec<RelaySubscription>, 40 published_events: Vec<Event>, 41 publish_outcomes_by_pubkey: HashMap<String, VecDeque<bool>>, 42 } 43 44 struct TestRelay { 45 url: String, 46 state: Arc<Mutex<RelayState>>, 47 notify: Arc<Notify>, 48 shutdown_tx: Option<oneshot::Sender<()>>, 49 } 50 51 impl TestRelay { 52 async fn spawn() -> TestResult<Self> { 53 let listener = TcpListener::bind("127.0.0.1:0").await?; 54 let addr = listener.local_addr()?; 55 let url = format!("ws://{addr}"); 56 let state = Arc::new(Mutex::new(RelayState::default())); 57 let notify = Arc::new(Notify::new()); 58 let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); 59 let relay_state = Arc::clone(&state); 60 let relay_notify = Arc::clone(¬ify); 61 62 tokio::spawn(async move { 63 loop { 64 tokio::select! { 65 _ = &mut shutdown_rx => break, 66 accept = listener.accept() => { 67 let Ok((stream, _)) = accept else { 68 break; 69 }; 70 let state = Arc::clone(&relay_state); 71 let notify = Arc::clone(&relay_notify); 72 tokio::spawn(async move { 73 let _ = handle_relay_connection(stream, state, notify).await; 74 }); 75 } 76 } 77 } 78 }); 79 80 Ok(Self { 81 url, 82 state, 83 notify, 84 shutdown_tx: Some(shutdown_tx), 85 }) 86 } 87 88 fn url(&self) -> &str { 89 self.url.as_str() 90 } 91 92 async fn queue_publish_outcomes(&self, public_key: PublicKey, outcomes: &[bool]) { 93 let mut state = self.state.lock().await; 94 state 95 .publish_outcomes_by_pubkey 96 .insert(public_key.to_hex(), outcomes.iter().copied().collect()); 97 } 98 99 async fn wait_for_published_events_by_author( 100 &self, 101 public_key: PublicKey, 102 expected: usize, 103 ) -> TestResult<Vec<Event>> { 104 timeout(RELAY_EVENT_TIMEOUT, async { 105 loop { 106 let events = self.published_events_by_author(public_key).await; 107 if events.len() >= expected { 108 return events; 109 } 110 self.notify.notified().await; 111 } 112 }) 113 .await 114 .map_err(Into::into) 115 } 116 117 async fn published_events_by_author(&self, public_key: PublicKey) -> Vec<Event> { 118 self.state 119 .lock() 120 .await 121 .published_events 122 .iter() 123 .filter(|event| event.pubkey == public_key) 124 .cloned() 125 .collect() 126 } 127 } 128 129 impl Drop for TestRelay { 130 fn drop(&mut self) { 131 if let Some(shutdown_tx) = self.shutdown_tx.take() { 132 let _ = shutdown_tx.send(()); 133 } 134 } 135 } 136 137 async fn handle_relay_connection( 138 stream: TcpStream, 139 state: Arc<Mutex<RelayState>>, 140 notify: Arc<Notify>, 141 ) -> TestResult<()> { 142 let websocket = tokio_tungstenite::accept_async(stream).await?; 143 let (mut writer, mut reader) = websocket.split(); 144 let (tx, mut rx) = mpsc::unbounded_channel::<Message>(); 145 let connection_id = { 146 let mut state = state.lock().await; 147 let connection_id = state.next_connection_id; 148 state.next_connection_id += 1; 149 state.senders.insert(connection_id, tx); 150 notify.notify_waiters(); 151 connection_id 152 }; 153 154 let writer_task = tokio::spawn(async move { 155 while let Some(message) = rx.recv().await { 156 if writer.send(message).await.is_err() { 157 break; 158 } 159 } 160 }); 161 162 while let Some(message) = reader.next().await { 163 let message = message?; 164 let Message::Text(text) = message else { 165 continue; 166 }; 167 let client_message = ClientMessage::from_json(text.as_str())?; 168 handle_client_message(connection_id, client_message, &state, ¬ify).await?; 169 } 170 171 writer_task.abort(); 172 let mut state = state.lock().await; 173 state.senders.remove(&connection_id); 174 state 175 .subscriptions 176 .retain(|subscription| subscription.connection_id != connection_id); 177 notify.notify_waiters(); 178 Ok(()) 179 } 180 181 async fn handle_client_message( 182 connection_id: usize, 183 client_message: ClientMessage<'_>, 184 state: &Arc<Mutex<RelayState>>, 185 notify: &Arc<Notify>, 186 ) -> TestResult<()> { 187 match client_message { 188 ClientMessage::Req { 189 subscription_id, 190 filters, 191 } => { 192 let (sender, matching_events) = { 193 let mut state = state.lock().await; 194 let matching_events = state 195 .published_events 196 .iter() 197 .filter(|event| { 198 filters 199 .iter() 200 .any(|filter| filter.match_event(event, MatchEventOptions::new())) 201 }) 202 .cloned() 203 .collect::<Vec<_>>(); 204 state.subscriptions.push(RelaySubscription { 205 connection_id, 206 subscription_id: subscription_id.as_ref().clone(), 207 filters: filters 208 .into_iter() 209 .map(|filter| filter.into_owned()) 210 .collect(), 211 }); 212 notify.notify_waiters(); 213 (state.senders.get(&connection_id).cloned(), matching_events) 214 }; 215 if let Some(sender) = sender { 216 for event in matching_events { 217 let message = 218 RelayMessage::event(subscription_id.as_ref().clone(), event).as_json(); 219 let _ = sender.send(Message::Text(message.into())); 220 } 221 let eose = RelayMessage::eose(subscription_id.as_ref().clone()).as_json(); 222 let _ = sender.send(Message::Text(eose.into())); 223 } 224 } 225 ClientMessage::Close(subscription_id) => { 226 let mut state = state.lock().await; 227 state.subscriptions.retain(|subscription| { 228 subscription.connection_id != connection_id 229 || subscription.subscription_id != *subscription_id 230 }); 231 notify.notify_waiters(); 232 } 233 ClientMessage::Event(event) => { 234 let event = event.into_owned(); 235 let (ok_message, subscriber_messages) = 236 accept_published_event(connection_id, event, state, notify).await?; 237 if let Some((sender, message)) = ok_message { 238 let _ = sender.send(message); 239 } 240 for (sender, message) in subscriber_messages { 241 let _ = sender.send(message); 242 } 243 } 244 _ => {} 245 } 246 247 Ok(()) 248 } 249 250 async fn accept_published_event( 251 connection_id: usize, 252 event: Event, 253 state: &Arc<Mutex<RelayState>>, 254 notify: &Arc<Notify>, 255 ) -> TestResult<( 256 Option<(mpsc::UnboundedSender<Message>, Message)>, 257 Vec<(mpsc::UnboundedSender<Message>, Message)>, 258 )> { 259 let event_id = event.id; 260 let event_pubkey_hex = event.pubkey.to_hex(); 261 let mut subscriber_messages = Vec::new(); 262 let mut ok_message = None; 263 264 { 265 let mut state = state.lock().await; 266 let publish_status = state 267 .publish_outcomes_by_pubkey 268 .get_mut(&event_pubkey_hex) 269 .and_then(|outcomes| outcomes.pop_front()) 270 .unwrap_or(true); 271 272 if let Some(sender) = state.senders.get(&connection_id).cloned() { 273 let message = if publish_status { 274 RelayMessage::ok(event_id, true, "").as_json() 275 } else { 276 RelayMessage::ok(event_id, false, "blocked by test relay").as_json() 277 }; 278 ok_message = Some((sender, Message::Text(message.into()))); 279 } 280 281 if publish_status { 282 state.published_events.push(event.clone()); 283 for subscription in &state.subscriptions { 284 if subscription 285 .filters 286 .iter() 287 .any(|filter| filter.match_event(&event, MatchEventOptions::new())) 288 { 289 if let Some(sender) = state.senders.get(&subscription.connection_id).cloned() { 290 let message = RelayMessage::event( 291 subscription.subscription_id.clone(), 292 event.clone(), 293 ) 294 .as_json(); 295 subscriber_messages.push((sender, Message::Text(message.into()))); 296 } 297 } 298 } 299 } 300 notify.notify_waiters(); 301 } 302 303 Ok((ok_message, subscriber_messages)) 304 } 305 306 fn write_identity(path: &Path, secret_key: &str) { 307 let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); 308 myc::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); 309 } 310 311 fn write_env_file( 312 path: &Path, 313 state_dir: &Path, 314 signer_identity_path: &Path, 315 user_identity_path: &Path, 316 app_identity_path: &Path, 317 relay_urls: &[&str], 318 ) { 319 let relay_list = relay_urls.join(","); 320 let env_file = format!( 321 r#"MYC_SERVICE_INSTANCE_NAME=myc 322 MYC_LOGGING_FILTER=info,myc=info 323 MYC_PATHS_STATE_DIR={state_dir} 324 MYC_IDENTITY_SIGNER_PATH={signer_identity_path} 325 MYC_IDENTITY_USER_PATH={user_identity_path} 326 MYC_AUDIT_DEFAULT_READ_LIMIT=200 327 MYC_AUDIT_MAX_ACTIVE_FILE_BYTES=262144 328 MYC_AUDIT_MAX_ARCHIVED_FILES=8 329 MYC_DISCOVERY_ENABLED=true 330 MYC_DISCOVERY_DOMAIN=signer.example.com 331 MYC_DISCOVERY_HANDLER_IDENTIFIER=myc 332 MYC_IDENTITY_DISCOVERY_APP_PATH={app_identity_path} 333 MYC_DISCOVERY_PUBLIC_RELAY_URLS={relay_list} 334 MYC_DISCOVERY_PUBLISH_RELAY_URLS={relay_list} 335 MYC_DISCOVERY_NOSTR_CONNECT_URL_TEMPLATE=https://signer.example.com/connect?uri=<nostrconnect> 336 MYC_DISCOVERY_NIP05_OUTPUT_PATH={nip05_output_path} 337 MYC_DISCOVERY_METADATA_NAME=myc 338 MYC_DISCOVERY_METADATA_DISPLAY_NAME=Mycorrhiza 339 MYC_DISCOVERY_METADATA_ABOUT=NIP-46 signer 340 MYC_DISCOVERY_METADATA_WEBSITE=https://signer.example.com 341 MYC_DISCOVERY_METADATA_PICTURE=https://signer.example.com/logo.png 342 MYC_POLICY_CONNECTION_APPROVAL=explicit_user 343 MYC_TRANSPORT_ENABLED=false 344 MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=10 345 MYC_TRANSPORT_RELAY_URLS= 346 "#, 347 state_dir = state_dir.display(), 348 signer_identity_path = signer_identity_path.display(), 349 user_identity_path = user_identity_path.display(), 350 app_identity_path = app_identity_path.display(), 351 relay_list = relay_list, 352 nip05_output_path = state_dir.join("public/.well-known/nostr.json").display(), 353 ); 354 fs::write(path, env_file).expect("write env file"); 355 } 356 357 fn run_myc(env_path: &Path, args: &[&str]) -> TestResult<Output> { 358 Ok(Command::new(env!("CARGO_BIN_EXE_myc")) 359 .arg("--env-file") 360 .arg(env_path) 361 .args(args) 362 .output()?) 363 } 364 365 fn extract_discovery_attempt_id(stderr: &str) -> Option<&str> { 366 stderr 367 .lines() 368 .find_map(|line| line.strip_prefix("myc: discovery repair attempt id: ")) 369 } 370 371 fn extract_discovery_attempt_hint(stderr: &str) -> Option<Value> { 372 stderr.lines().find_map(|line| { 373 line.strip_prefix("myc: discovery repair attempt json: ") 374 .and_then(|json| serde_json::from_str(json).ok()) 375 }) 376 } 377 378 fn unavailable_relay_url() -> TestResult<String> { 379 let listener = StdTcpListener::bind("127.0.0.1:0")?; 380 let addr = listener.local_addr()?; 381 drop(listener); 382 Ok(format!("ws://{addr}")) 383 } 384 385 async fn publish_handler_event( 386 relay_url: &str, 387 identity: &RadrootsIdentity, 388 spec: &RadrootsNostrApplicationHandlerSpec, 389 ) -> TestResult<Event> { 390 let event = radroots_nostr_build_application_handler_event(spec)? 391 .sign_with_keys(identity.keys()) 392 .map_err(|error| format!("failed to sign handler event: {error}"))?; 393 let client = RadrootsNostrClient::from_identity(identity); 394 let _ = client.add_relay(relay_url).await?; 395 client.connect().await; 396 client.wait_for_connection(Duration::from_secs(1)).await; 397 let output = client.send_event(&event).await?; 398 assert!( 399 !output.success.is_empty(), 400 "handler event publish did not succeed: {:?}", 401 output.failed 402 ); 403 Ok(event) 404 } 405 406 #[test] 407 fn export_bundle_and_verify_bundle_work_through_the_cli() -> TestResult<()> { 408 let temp = tempfile::tempdir()?; 409 let env_path = temp.path().join(".env"); 410 let state_dir = temp.path().join("state"); 411 let signer_identity_path = temp.path().join("signer.json"); 412 let user_identity_path = temp.path().join("user.json"); 413 let app_identity_path = temp.path().join("app.json"); 414 let bundle_dir = temp.path().join("bundle"); 415 416 write_identity( 417 &signer_identity_path, 418 "1111111111111111111111111111111111111111111111111111111111111111", 419 ); 420 write_identity( 421 &user_identity_path, 422 "2222222222222222222222222222222222222222222222222222222222222222", 423 ); 424 write_identity( 425 &app_identity_path, 426 "3333333333333333333333333333333333333333333333333333333333333333", 427 ); 428 write_env_file( 429 &env_path, 430 &state_dir, 431 &signer_identity_path, 432 &user_identity_path, 433 &app_identity_path, 434 &["wss://relay.example.com"], 435 ); 436 437 let export = run_myc( 438 &env_path, 439 &[ 440 "discovery", 441 "export-bundle", 442 "--out", 443 bundle_dir.to_str().unwrap(), 444 ], 445 )?; 446 447 assert!( 448 export.status.success(), 449 "export-bundle failed: {}", 450 String::from_utf8_lossy(&export.stderr) 451 ); 452 let export_output: Value = serde_json::from_slice(&export.stdout)?; 453 assert_eq!(export_output["manifest"]["domain"], "signer.example.com"); 454 assert!(bundle_dir.join("bundle.json").exists()); 455 assert!(bundle_dir.join(".well-known/nostr.json").exists()); 456 assert!(bundle_dir.join("nip89-handler.json").exists()); 457 458 let verify = run_myc( 459 &env_path, 460 &[ 461 "discovery", 462 "verify-bundle", 463 "--dir", 464 bundle_dir.to_str().unwrap(), 465 ], 466 )?; 467 468 assert!( 469 verify.status.success(), 470 "verify-bundle failed: {}", 471 String::from_utf8_lossy(&verify.stderr) 472 ); 473 let verify_output: Value = serde_json::from_slice(&verify.stdout)?; 474 assert_eq!(verify_output["manifest"]["domain"], "signer.example.com"); 475 assert_eq!( 476 verify_output["manifest"]["nip05_relative_path"], 477 ".well-known/nostr.json" 478 ); 479 assert_eq!( 480 verify_output["manifest"]["nip89_relative_path"], 481 "nip89-handler.json" 482 ); 483 484 Ok(()) 485 } 486 487 #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 488 async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> { 489 let relay = TestRelay::spawn().await?; 490 let temp = tempfile::tempdir()?; 491 let env_path = temp.path().join(".env"); 492 let state_dir = temp.path().join("state"); 493 let signer_identity_path = temp.path().join("signer.json"); 494 let user_identity_path = temp.path().join("user.json"); 495 let app_identity_path = temp.path().join("app.json"); 496 let app_identity = RadrootsIdentity::from_secret_key_str( 497 "3333333333333333333333333333333333333333333333333333333333333333", 498 )?; 499 500 write_identity( 501 &signer_identity_path, 502 "1111111111111111111111111111111111111111111111111111111111111111", 503 ); 504 write_identity( 505 &user_identity_path, 506 "2222222222222222222222222222222222222222222222222222222222222222", 507 ); 508 myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; 509 write_env_file( 510 &env_path, 511 &state_dir, 512 &signer_identity_path, 513 &user_identity_path, 514 &app_identity_path, 515 &[relay.url()], 516 ); 517 518 let inspect_missing = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; 519 assert!( 520 inspect_missing.status.success(), 521 "inspect-live-nip89 failed: {}", 522 String::from_utf8_lossy(&inspect_missing.stderr) 523 ); 524 let inspect_missing_output: Value = serde_json::from_slice(&inspect_missing.stdout)?; 525 assert_eq!( 526 inspect_missing_output["live_groups"] 527 .as_array() 528 .unwrap() 529 .len(), 530 0 531 ); 532 assert_eq!( 533 inspect_missing_output["relay_states"] 534 .as_array() 535 .unwrap() 536 .len(), 537 1 538 ); 539 540 let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; 541 assert!( 542 refresh.status.success(), 543 "refresh-nip89 failed: {}", 544 String::from_utf8_lossy(&refresh.stderr) 545 ); 546 let refresh_output: Value = serde_json::from_slice(&refresh.stdout)?; 547 assert_eq!(refresh_output["status"], "missing"); 548 assert!(refresh_output["published"].is_object()); 549 550 relay 551 .wait_for_published_events_by_author(app_identity.public_key(), 1) 552 .await?; 553 554 let inspect_live = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; 555 assert!( 556 inspect_live.status.success(), 557 "inspect-live-nip89 after refresh failed: {}", 558 String::from_utf8_lossy(&inspect_live.stderr) 559 ); 560 let inspect_live_output: Value = serde_json::from_slice(&inspect_live.stdout)?; 561 assert_eq!( 562 inspect_live_output["live_groups"].as_array().unwrap().len(), 563 1 564 ); 565 assert_eq!( 566 inspect_live_output["live_groups"][0]["source_relays"] 567 .as_array() 568 .unwrap() 569 .len(), 570 1 571 ); 572 573 let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; 574 assert!( 575 diff.status.success(), 576 "diff-live-nip89 failed: {}", 577 String::from_utf8_lossy(&diff.stderr) 578 ); 579 let diff_output: Value = serde_json::from_slice(&diff.stdout)?; 580 assert_eq!(diff_output["status"], "matched"); 581 assert_eq!(diff_output["live_groups"].as_array().unwrap().len(), 1); 582 assert_eq!( 583 diff_output["relay_summary"]["matched_relays"] 584 .as_array() 585 .unwrap() 586 .len(), 587 1 588 ); 589 assert_eq!( 590 diff_output["relay_states"][0]["fetch_status"], 591 Value::String("available".to_owned()) 592 ); 593 assert_eq!( 594 diff_output["relay_states"][0]["live_status"], 595 Value::String("matched".to_owned()) 596 ); 597 598 Ok(()) 599 } 600 601 #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 602 async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> { 603 let relay = TestRelay::spawn().await?; 604 let temp = tempfile::tempdir()?; 605 let env_path = temp.path().join(".env"); 606 let state_dir = temp.path().join("state"); 607 let signer_identity_path = temp.path().join("signer.json"); 608 let user_identity_path = temp.path().join("user.json"); 609 let app_identity_path = temp.path().join("app.json"); 610 let app_identity = RadrootsIdentity::from_secret_key_str( 611 "3333333333333333333333333333333333333333333333333333333333333333", 612 )?; 613 614 write_identity( 615 &signer_identity_path, 616 "1111111111111111111111111111111111111111111111111111111111111111", 617 ); 618 write_identity( 619 &user_identity_path, 620 "2222222222222222222222222222222222222222222222222222222222222222", 621 ); 622 myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; 623 write_env_file( 624 &env_path, 625 &state_dir, 626 &signer_identity_path, 627 &user_identity_path, 628 &app_identity_path, 629 &[relay.url()], 630 ); 631 632 let mut first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]); 633 first_spec.identifier = Some("myc".to_owned()); 634 first_spec.relays = vec!["wss://relay-a.example.com".to_owned()]; 635 publish_handler_event(relay.url(), &app_identity, &first_spec).await?; 636 637 let mut second_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]); 638 second_spec.identifier = Some("myc".to_owned()); 639 second_spec.relays = vec!["wss://relay-b.example.com".to_owned()]; 640 let mut metadata = RadrootsNostrMetadata::default(); 641 metadata.name = Some("conflict".to_owned()); 642 second_spec.metadata = Some(metadata); 643 publish_handler_event(relay.url(), &app_identity, &second_spec).await?; 644 645 relay 646 .wait_for_published_events_by_author(app_identity.public_key(), 2) 647 .await?; 648 649 let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; 650 assert!( 651 diff.status.success(), 652 "diff-live-nip89 failed: {}", 653 String::from_utf8_lossy(&diff.stderr) 654 ); 655 let diff_output: Value = serde_json::from_slice(&diff.stdout)?; 656 assert_eq!(diff_output["status"], "conflicted"); 657 assert_eq!(diff_output["live_groups"].as_array().unwrap().len(), 2); 658 assert_eq!( 659 diff_output["relay_summary"]["conflicted_relays"] 660 .as_array() 661 .unwrap() 662 .len(), 663 1 664 ); 665 assert!( 666 diff_output["relay_summary"]["unavailable_relays"] 667 .as_array() 668 .unwrap() 669 .is_empty() 670 ); 671 672 let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; 673 assert!( 674 !refresh.status.success(), 675 "refresh-nip89 unexpectedly succeeded: {}", 676 String::from_utf8_lossy(&refresh.stdout) 677 ); 678 assert!( 679 String::from_utf8_lossy(&refresh.stderr).contains("conflicted"), 680 "unexpected refresh stderr: {}", 681 String::from_utf8_lossy(&refresh.stderr) 682 ); 683 let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); 684 let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); 685 let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); 686 assert_eq!( 687 attempt_hint["attempt_id"], 688 Value::String(attempt_id.to_owned()) 689 ); 690 assert_eq!( 691 attempt_hint["inspect_args"], 692 Value::Array(vec![ 693 Value::String("audit".to_owned()), 694 Value::String("discovery-repair-attempt".to_owned()), 695 Value::String("--attempt-id".to_owned()), 696 Value::String(attempt_id.to_owned()), 697 ]) 698 ); 699 let attempt = run_myc( 700 &env_path, 701 &[ 702 "audit", 703 "discovery-repair-attempt", 704 "--attempt-id", 705 attempt_id, 706 ], 707 )?; 708 assert!( 709 attempt.status.success(), 710 "discovery-repair-attempt failed: {}", 711 String::from_utf8_lossy(&attempt.stderr) 712 ); 713 let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; 714 assert_eq!( 715 attempt_output["attempt_id"], 716 Value::String(attempt_id.to_owned()) 717 ); 718 assert_eq!( 719 attempt_output["refresh_outcome"], 720 Value::String("conflicted".to_owned()) 721 ); 722 assert_eq!( 723 attempt_output["planned_repair_relays"], 724 Value::Array(vec![Value::String(relay.url().to_owned())]) 725 ); 726 assert_eq!( 727 attempt_output["blocked_relays"], 728 Value::Array(vec![Value::String(relay.url().to_owned())]) 729 ); 730 assert_eq!( 731 attempt_output["blocked_reason"], 732 Value::String("conflicted_relays".to_owned()) 733 ); 734 assert_eq!( 735 attempt_output["remaining_repair_relays"], 736 Value::Array(vec![Value::String(relay.url().to_owned())]) 737 ); 738 739 let forced_refresh = run_myc(&env_path, &["discovery", "refresh-nip89", "--force"])?; 740 assert!( 741 forced_refresh.status.success(), 742 "refresh-nip89 --force failed: {}", 743 String::from_utf8_lossy(&forced_refresh.stderr) 744 ); 745 let forced_refresh_output: Value = serde_json::from_slice(&forced_refresh.stdout)?; 746 assert_eq!(forced_refresh_output["status"], "conflicted"); 747 assert!(forced_refresh_output["published"].is_object()); 748 749 Ok(()) 750 } 751 752 #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 753 async fn refresh_reports_partial_repair_and_audit_summary_through_the_cli() -> TestResult<()> { 754 let relay_a = TestRelay::spawn().await?; 755 let relay_b = TestRelay::spawn().await?; 756 let temp = tempfile::tempdir()?; 757 let env_path = temp.path().join(".env"); 758 let state_dir = temp.path().join("state"); 759 let signer_identity_path = temp.path().join("signer.json"); 760 let user_identity_path = temp.path().join("user.json"); 761 let app_identity_path = temp.path().join("app.json"); 762 let app_identity = RadrootsIdentity::from_secret_key_str( 763 "3333333333333333333333333333333333333333333333333333333333333333", 764 )?; 765 766 write_identity( 767 &signer_identity_path, 768 "1111111111111111111111111111111111111111111111111111111111111111", 769 ); 770 write_identity( 771 &user_identity_path, 772 "2222222222222222222222222222222222222222222222222222222222222222", 773 ); 774 myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; 775 write_env_file( 776 &env_path, 777 &state_dir, 778 &signer_identity_path, 779 &user_identity_path, 780 &app_identity_path, 781 &[relay_a.url(), relay_b.url()], 782 ); 783 784 relay_a 785 .queue_publish_outcomes(app_identity.public_key(), &[true]) 786 .await; 787 relay_b 788 .queue_publish_outcomes(app_identity.public_key(), &[false]) 789 .await; 790 791 let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; 792 assert!( 793 refresh.status.success(), 794 "refresh-nip89 failed: {}", 795 String::from_utf8_lossy(&refresh.stderr) 796 ); 797 let refresh_output: Value = serde_json::from_slice(&refresh.stdout)?; 798 assert_eq!(refresh_output["status"], "missing"); 799 assert_eq!(refresh_output["repair_summary"]["repaired"], 1); 800 assert_eq!(refresh_output["repair_summary"]["failed"], 1); 801 assert_eq!(refresh_output["repair_summary"]["unchanged"], 0); 802 assert_eq!(refresh_output["repair_summary"]["skipped"], 0); 803 assert_eq!( 804 refresh_output["remaining_repair_relays"], 805 Value::Array(vec![Value::String(relay_b.url().to_owned())]) 806 ); 807 assert_eq!( 808 refresh_output["published"]["acknowledged_relay_count"], 809 Value::from(1_u64) 810 ); 811 812 relay_a 813 .wait_for_published_events_by_author(app_identity.public_key(), 1) 814 .await?; 815 assert_eq!( 816 relay_b 817 .published_events_by_author(app_identity.public_key()) 818 .await 819 .len(), 820 0 821 ); 822 823 let audit_summary = run_myc(&env_path, &["audit", "summary", "--scope", "operation"])?; 824 assert!( 825 audit_summary.status.success(), 826 "audit summary failed: {}", 827 String::from_utf8_lossy(&audit_summary.stderr) 828 ); 829 let audit_summary_output: Value = serde_json::from_slice(&audit_summary.stdout)?; 830 assert_eq!( 831 audit_summary_output["runtime_aggregate_publish_rejection_count"], 832 Value::from(0_u64) 833 ); 834 assert_eq!( 835 audit_summary_output["runtime_repair_success_count"], 836 Value::from(1_u64) 837 ); 838 assert_eq!( 839 audit_summary_output["runtime_repair_rejection_count"], 840 Value::from(1_u64) 841 ); 842 assert_eq!( 843 audit_summary_output["runtime_operation_by_kind"]["discovery_handler_publish"]["succeeded"], 844 Value::from(1_u64) 845 ); 846 assert_eq!( 847 audit_summary_output["runtime_operation_by_kind"]["discovery_handler_repair"]["rejected"], 848 Value::from(1_u64) 849 ); 850 851 Ok(()) 852 } 853 854 #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 855 async fn failed_refresh_publish_surfaces_attempt_id_and_exact_audit_lookup() -> TestResult<()> { 856 let relay = TestRelay::spawn().await?; 857 let temp = tempfile::tempdir()?; 858 let env_path = temp.path().join(".env"); 859 let state_dir = temp.path().join("state"); 860 let signer_identity_path = temp.path().join("signer.json"); 861 let user_identity_path = temp.path().join("user.json"); 862 let app_identity_path = temp.path().join("app.json"); 863 let app_identity = RadrootsIdentity::from_secret_key_str( 864 "3333333333333333333333333333333333333333333333333333333333333333", 865 )?; 866 867 write_identity( 868 &signer_identity_path, 869 "1111111111111111111111111111111111111111111111111111111111111111", 870 ); 871 write_identity( 872 &user_identity_path, 873 "2222222222222222222222222222222222222222222222222222222222222222", 874 ); 875 myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; 876 write_env_file( 877 &env_path, 878 &state_dir, 879 &signer_identity_path, 880 &user_identity_path, 881 &app_identity_path, 882 &[relay.url()], 883 ); 884 885 relay 886 .queue_publish_outcomes(app_identity.public_key(), &[false]) 887 .await; 888 889 let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; 890 assert!( 891 !refresh.status.success(), 892 "refresh-nip89 unexpectedly succeeded: {}", 893 String::from_utf8_lossy(&refresh.stdout) 894 ); 895 let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); 896 assert!( 897 refresh_stderr.contains("Nostr publish failed"), 898 "unexpected refresh stderr: {refresh_stderr}" 899 ); 900 let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); 901 let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); 902 assert_eq!( 903 attempt_hint["attempt_id"], 904 Value::String(attempt_id.to_owned()) 905 ); 906 907 let attempt = run_myc( 908 &env_path, 909 &[ 910 "audit", 911 "discovery-repair-attempt", 912 "--attempt-id", 913 attempt_id, 914 ], 915 )?; 916 assert!( 917 attempt.status.success(), 918 "discovery-repair-attempt failed: {}", 919 String::from_utf8_lossy(&attempt.stderr) 920 ); 921 let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; 922 assert_eq!( 923 attempt_output["attempt_id"], 924 Value::String(attempt_id.to_owned()) 925 ); 926 assert_eq!( 927 attempt_output["refresh_outcome"], 928 Value::String("rejected".to_owned()) 929 ); 930 assert_eq!( 931 attempt_output["aggregate_publish_outcome"], 932 Value::String("rejected".to_owned()) 933 ); 934 assert_eq!( 935 attempt_output["repair_summary"]["failed"], 936 Value::from(1_u64) 937 ); 938 assert_eq!( 939 attempt_output["remaining_repair_relays"], 940 Value::Array(vec![Value::String(relay.url().to_owned())]) 941 ); 942 assert_eq!( 943 attempt_output["planned_repair_relays"], 944 Value::Array(vec![Value::String(relay.url().to_owned())]) 945 ); 946 assert_eq!(attempt_output["blocked_relays"], Value::Array(vec![])); 947 assert!(attempt_output["blocked_reason"].is_null()); 948 949 Ok(()) 950 } 951 952 #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 953 async fn discovery_repair_attempt_commands_correlate_multiple_refresh_runs() -> TestResult<()> { 954 let relay_a = TestRelay::spawn().await?; 955 let relay_b = TestRelay::spawn().await?; 956 let temp = tempfile::tempdir()?; 957 let env_path = temp.path().join(".env"); 958 let state_dir = temp.path().join("state"); 959 let signer_identity_path = temp.path().join("signer.json"); 960 let user_identity_path = temp.path().join("user.json"); 961 let app_identity_path = temp.path().join("app.json"); 962 let app_identity = RadrootsIdentity::from_secret_key_str( 963 "3333333333333333333333333333333333333333333333333333333333333333", 964 )?; 965 966 write_identity( 967 &signer_identity_path, 968 "1111111111111111111111111111111111111111111111111111111111111111", 969 ); 970 write_identity( 971 &user_identity_path, 972 "2222222222222222222222222222222222222222222222222222222222222222", 973 ); 974 myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; 975 write_env_file( 976 &env_path, 977 &state_dir, 978 &signer_identity_path, 979 &user_identity_path, 980 &app_identity_path, 981 &[relay_a.url(), relay_b.url()], 982 ); 983 984 relay_a 985 .queue_publish_outcomes(app_identity.public_key(), &[true]) 986 .await; 987 relay_b 988 .queue_publish_outcomes(app_identity.public_key(), &[false, true]) 989 .await; 990 991 let first_refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; 992 assert!( 993 first_refresh.status.success(), 994 "first refresh-nip89 failed: {}", 995 String::from_utf8_lossy(&first_refresh.stderr) 996 ); 997 let first_refresh_output: Value = serde_json::from_slice(&first_refresh.stdout)?; 998 let first_attempt_id = first_refresh_output["attempt_id"] 999 .as_str() 1000 .expect("first attempt id") 1001 .to_owned(); 1002 assert_eq!(first_refresh_output["repair_summary"]["repaired"], 1); 1003 assert_eq!(first_refresh_output["repair_summary"]["failed"], 1); 1004 assert_eq!( 1005 first_refresh_output["remaining_repair_relays"], 1006 Value::Array(vec![Value::String(relay_b.url().to_owned())]) 1007 ); 1008 1009 relay_a 1010 .wait_for_published_events_by_author(app_identity.public_key(), 1) 1011 .await?; 1012 1013 let second_refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; 1014 assert!( 1015 second_refresh.status.success(), 1016 "second refresh-nip89 failed: {}", 1017 String::from_utf8_lossy(&second_refresh.stderr) 1018 ); 1019 let second_refresh_output: Value = serde_json::from_slice(&second_refresh.stdout)?; 1020 let second_attempt_id = second_refresh_output["attempt_id"] 1021 .as_str() 1022 .expect("second attempt id") 1023 .to_owned(); 1024 assert_ne!(first_attempt_id, second_attempt_id); 1025 assert_eq!(second_refresh_output["repair_summary"]["repaired"], 1); 1026 assert_eq!(second_refresh_output["repair_summary"]["failed"], 0); 1027 assert_eq!(second_refresh_output["repair_summary"]["unchanged"], 1); 1028 assert_eq!( 1029 second_refresh_output["remaining_repair_relays"], 1030 Value::Array(vec![]) 1031 ); 1032 1033 let latest_attempt = run_myc(&env_path, &["audit", "latest-discovery-repair"])?; 1034 assert!( 1035 latest_attempt.status.success(), 1036 "latest-discovery-repair failed: {}", 1037 String::from_utf8_lossy(&latest_attempt.stderr) 1038 ); 1039 let latest_attempt_output: Value = serde_json::from_slice(&latest_attempt.stdout)?; 1040 assert_eq!( 1041 latest_attempt_output["attempt_id"], 1042 Value::String(second_attempt_id.clone()) 1043 ); 1044 assert_eq!( 1045 latest_attempt_output["compare_outcome"], 1046 Value::String("matched".to_owned()) 1047 ); 1048 assert_eq!( 1049 latest_attempt_output["refresh_outcome"], 1050 Value::String("succeeded".to_owned()) 1051 ); 1052 assert_eq!(latest_attempt_output["repair_summary"]["repaired"], 1); 1053 assert_eq!(latest_attempt_output["repair_summary"]["failed"], 0); 1054 assert_eq!(latest_attempt_output["repair_summary"]["unchanged"], 1); 1055 assert_eq!( 1056 latest_attempt_output["remaining_repair_relays"], 1057 Value::Array(vec![]) 1058 ); 1059 1060 let first_attempt_summary = run_myc( 1061 &env_path, 1062 &[ 1063 "audit", 1064 "discovery-repair-attempt", 1065 "--attempt-id", 1066 first_attempt_id.as_str(), 1067 ], 1068 )?; 1069 assert!( 1070 first_attempt_summary.status.success(), 1071 "discovery-repair-attempt summary failed: {}", 1072 String::from_utf8_lossy(&first_attempt_summary.stderr) 1073 ); 1074 let first_attempt_summary_output: Value = 1075 serde_json::from_slice(&first_attempt_summary.stdout)?; 1076 assert_eq!( 1077 first_attempt_summary_output["attempt_id"], 1078 Value::String(first_attempt_id.clone()) 1079 ); 1080 assert_eq!( 1081 first_attempt_summary_output["refresh_outcome"], 1082 Value::String("succeeded".to_owned()) 1083 ); 1084 assert_eq!( 1085 first_attempt_summary_output["repair_summary"]["repaired"], 1086 1 1087 ); 1088 assert_eq!(first_attempt_summary_output["repair_summary"]["failed"], 1); 1089 assert_eq!( 1090 first_attempt_summary_output["failed_relays"], 1091 Value::Array(vec![Value::String(relay_b.url().to_owned())]) 1092 ); 1093 assert_eq!( 1094 first_attempt_summary_output["remaining_repair_relays"], 1095 Value::Array(vec![Value::String(relay_b.url().to_owned())]) 1096 ); 1097 1098 let first_attempt_records = run_myc( 1099 &env_path, 1100 &[ 1101 "audit", 1102 "discovery-repair-attempt", 1103 "--attempt-id", 1104 first_attempt_id.as_str(), 1105 "--view", 1106 "records", 1107 ], 1108 )?; 1109 assert!( 1110 first_attempt_records.status.success(), 1111 "discovery-repair-attempt records failed: {}", 1112 String::from_utf8_lossy(&first_attempt_records.stderr) 1113 ); 1114 let first_attempt_records_output: Value = 1115 serde_json::from_slice(&first_attempt_records.stdout)?; 1116 let record_attempt_ids = first_attempt_records_output["runtime_operation_audit"] 1117 .as_array() 1118 .expect("attempt records") 1119 .iter() 1120 .map(|record| record["attempt_id"].as_str().expect("record attempt id")) 1121 .collect::<Vec<_>>(); 1122 assert!(!record_attempt_ids.is_empty()); 1123 assert!( 1124 record_attempt_ids 1125 .iter() 1126 .all(|attempt_id| *attempt_id == first_attempt_id) 1127 ); 1128 1129 Ok(()) 1130 } 1131 1132 #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 1133 async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResult<()> { 1134 let relay_a = TestRelay::spawn().await?; 1135 let relay_b = TestRelay::spawn().await?; 1136 let temp = tempfile::tempdir()?; 1137 let env_path = temp.path().join(".env"); 1138 let state_dir = temp.path().join("state"); 1139 let signer_identity_path = temp.path().join("signer.json"); 1140 let user_identity_path = temp.path().join("user.json"); 1141 let app_identity_path = temp.path().join("app.json"); 1142 let app_identity = RadrootsIdentity::from_secret_key_str( 1143 "3333333333333333333333333333333333333333333333333333333333333333", 1144 )?; 1145 let signer_identity = RadrootsIdentity::from_secret_key_str( 1146 "1111111111111111111111111111111111111111111111111111111111111111", 1147 )?; 1148 1149 write_identity( 1150 &signer_identity_path, 1151 "1111111111111111111111111111111111111111111111111111111111111111", 1152 ); 1153 write_identity( 1154 &user_identity_path, 1155 "2222222222222222222222222222222222222222222222222222222222222222", 1156 ); 1157 myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; 1158 write_env_file( 1159 &env_path, 1160 &state_dir, 1161 &signer_identity_path, 1162 &user_identity_path, 1163 &app_identity_path, 1164 &[relay_a.url(), relay_b.url()], 1165 ); 1166 1167 let mut matched_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]); 1168 matched_spec.identifier = Some("myc".to_owned()); 1169 matched_spec.relays = vec![relay_a.url().to_owned(), relay_b.url().to_owned()]; 1170 let bunker_uri = RadrootsNostrConnectUri::Bunker(RadrootsNostrConnectBunkerUri { 1171 remote_signer_public_key: signer_identity.public_key(), 1172 relays: vec![ 1173 relay_a.url().parse().expect("relay a url"), 1174 relay_b.url().parse().expect("relay b url"), 1175 ], 1176 secret: None, 1177 }) 1178 .to_string(); 1179 let encoded_bunker_uri: String = 1180 url::form_urlencoded::byte_serialize(bunker_uri.as_bytes()).collect(); 1181 matched_spec.nostrconnect_url = Some(format!( 1182 "https://signer.example.com/connect?uri={encoded_bunker_uri}" 1183 )); 1184 let mut matched_metadata = RadrootsNostrMetadata::default(); 1185 matched_metadata.name = Some("myc".to_owned()); 1186 matched_metadata.display_name = Some("Mycorrhiza".to_owned()); 1187 matched_metadata.about = Some("NIP-46 signer".to_owned()); 1188 matched_metadata.website = Some("https://signer.example.com".to_owned()); 1189 matched_metadata.picture = Some("https://signer.example.com/logo.png".to_owned()); 1190 matched_spec.metadata = Some(matched_metadata); 1191 publish_handler_event(relay_a.url(), &app_identity, &matched_spec).await?; 1192 1193 let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]); 1194 drifted_spec.identifier = Some("myc".to_owned()); 1195 drifted_spec.relays = vec!["wss://stale.example.com".to_owned()]; 1196 let mut drifted_metadata = RadrootsNostrMetadata::default(); 1197 drifted_metadata.name = Some("stale".to_owned()); 1198 drifted_spec.metadata = Some(drifted_metadata); 1199 publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?; 1200 1201 relay_a 1202 .wait_for_published_events_by_author(app_identity.public_key(), 1) 1203 .await?; 1204 relay_b 1205 .wait_for_published_events_by_author(app_identity.public_key(), 1) 1206 .await?; 1207 1208 let inspect = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; 1209 assert!( 1210 inspect.status.success(), 1211 "inspect-live-nip89 failed: {}", 1212 String::from_utf8_lossy(&inspect.stderr) 1213 ); 1214 let inspect_output: Value = serde_json::from_slice(&inspect.stdout)?; 1215 assert_eq!(inspect_output["live_groups"].as_array().unwrap().len(), 2); 1216 assert_eq!(inspect_output["relay_states"].as_array().unwrap().len(), 2); 1217 let group_relays = inspect_output["live_groups"] 1218 .as_array() 1219 .unwrap() 1220 .iter() 1221 .map(|group| { 1222 group["source_relays"] 1223 .as_array() 1224 .unwrap() 1225 .iter() 1226 .map(|relay| relay.as_str().unwrap().to_owned()) 1227 .collect::<Vec<_>>() 1228 }) 1229 .collect::<Vec<_>>(); 1230 assert!( 1231 group_relays 1232 .iter() 1233 .any(|relays| relays == &vec![relay_a.url().to_owned()]) 1234 ); 1235 assert!( 1236 group_relays 1237 .iter() 1238 .any(|relays| relays == &vec![relay_b.url().to_owned()]) 1239 ); 1240 1241 let diff = run_myc(&env_path, &["discovery", "diff-live-nip89"])?; 1242 assert!( 1243 diff.status.success(), 1244 "diff-live-nip89 failed: {}", 1245 String::from_utf8_lossy(&diff.stderr) 1246 ); 1247 let diff_output: Value = serde_json::from_slice(&diff.stdout)?; 1248 assert_eq!(diff_output["status"], "conflicted"); 1249 assert_eq!( 1250 diff_output["relay_summary"]["matched_relays"], 1251 Value::Array(vec![Value::String(relay_a.url().to_owned())]) 1252 ); 1253 assert_eq!( 1254 diff_output["relay_summary"]["drifted_relays"], 1255 Value::Array(vec![Value::String(relay_b.url().to_owned())]) 1256 ); 1257 assert_eq!( 1258 diff_output["relay_summary"]["conflicted_relays"], 1259 Value::Array(vec![]) 1260 ); 1261 assert_eq!(diff_output["relay_states"].as_array().unwrap().len(), 2); 1262 for relay_state in diff_output["relay_states"].as_array().unwrap() { 1263 assert_eq!( 1264 relay_state["fetch_status"], 1265 Value::String("available".to_owned()) 1266 ); 1267 assert!(relay_state["live_status"].is_string()); 1268 } 1269 1270 Ok(()) 1271 } 1272 1273 #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 1274 async fn refresh_requires_force_when_a_discovery_relay_is_unavailable_through_the_cli() 1275 -> TestResult<()> { 1276 let relay = TestRelay::spawn().await?; 1277 let unavailable_relay = unavailable_relay_url()?; 1278 let temp = tempfile::tempdir()?; 1279 let env_path = temp.path().join(".env"); 1280 let state_dir = temp.path().join("state"); 1281 let signer_identity_path = temp.path().join("signer.json"); 1282 let user_identity_path = temp.path().join("user.json"); 1283 let app_identity_path = temp.path().join("app.json"); 1284 let app_identity = RadrootsIdentity::from_secret_key_str( 1285 "3333333333333333333333333333333333333333333333333333333333333333", 1286 )?; 1287 1288 write_identity( 1289 &signer_identity_path, 1290 "1111111111111111111111111111111111111111111111111111111111111111", 1291 ); 1292 write_identity( 1293 &user_identity_path, 1294 "2222222222222222222222222222222222222222222222222222222222222222", 1295 ); 1296 myc::identity_files::store_encrypted_identity(&app_identity_path, &app_identity)?; 1297 write_env_file( 1298 &env_path, 1299 &state_dir, 1300 &signer_identity_path, 1301 &user_identity_path, 1302 &app_identity_path, 1303 &[relay.url(), unavailable_relay.as_str()], 1304 ); 1305 1306 let inspect = run_myc(&env_path, &["discovery", "inspect-live-nip89"])?; 1307 assert!( 1308 inspect.status.success(), 1309 "inspect-live-nip89 failed: {}", 1310 String::from_utf8_lossy(&inspect.stderr) 1311 ); 1312 let inspect_output: Value = serde_json::from_slice(&inspect.stdout)?; 1313 assert_eq!(inspect_output["live_groups"].as_array().unwrap().len(), 0); 1314 assert_eq!(inspect_output["relay_states"].as_array().unwrap().len(), 2); 1315 assert!( 1316 inspect_output["relay_states"] 1317 .as_array() 1318 .unwrap() 1319 .iter() 1320 .any(|relay_state| { 1321 relay_state["relay_url"] == Value::String(unavailable_relay.clone()) 1322 && relay_state["fetch_status"] == Value::String("unavailable".to_owned()) 1323 && relay_state["live_status"].is_null() 1324 && relay_state["fetch_error"].is_string() 1325 }) 1326 ); 1327 1328 let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; 1329 assert!( 1330 !refresh.status.success(), 1331 "refresh-nip89 unexpectedly succeeded: {}", 1332 String::from_utf8_lossy(&refresh.stdout) 1333 ); 1334 assert!( 1335 String::from_utf8_lossy(&refresh.stderr).contains("unavailable"), 1336 "unexpected refresh stderr: {}", 1337 String::from_utf8_lossy(&refresh.stderr) 1338 ); 1339 let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); 1340 let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); 1341 let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); 1342 assert_eq!( 1343 attempt_hint["attempt_id"], 1344 Value::String(attempt_id.to_owned()) 1345 ); 1346 let attempt = run_myc( 1347 &env_path, 1348 &[ 1349 "audit", 1350 "discovery-repair-attempt", 1351 "--attempt-id", 1352 attempt_id, 1353 ], 1354 )?; 1355 assert!( 1356 attempt.status.success(), 1357 "discovery-repair-attempt failed: {}", 1358 String::from_utf8_lossy(&attempt.stderr) 1359 ); 1360 let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; 1361 assert_eq!( 1362 attempt_output["attempt_id"], 1363 Value::String(attempt_id.to_owned()) 1364 ); 1365 assert_eq!( 1366 attempt_output["refresh_outcome"], 1367 Value::String("unavailable".to_owned()) 1368 ); 1369 assert_eq!( 1370 attempt_output["planned_repair_relays"], 1371 Value::Array(vec![Value::String(relay.url().to_owned())]) 1372 ); 1373 assert_eq!( 1374 attempt_output["blocked_relays"], 1375 Value::Array(vec![Value::String(unavailable_relay.clone())]) 1376 ); 1377 assert_eq!( 1378 attempt_output["blocked_reason"], 1379 Value::String("unavailable_relays".to_owned()) 1380 ); 1381 assert_eq!( 1382 attempt_output["remaining_repair_relays"], 1383 Value::Array(vec![Value::String(relay.url().to_owned())]) 1384 ); 1385 1386 let forced_refresh = run_myc(&env_path, &["discovery", "refresh-nip89", "--force"])?; 1387 assert!( 1388 forced_refresh.status.success(), 1389 "refresh-nip89 --force failed: {}", 1390 String::from_utf8_lossy(&forced_refresh.stderr) 1391 ); 1392 let forced_refresh_output: Value = serde_json::from_slice(&forced_refresh.stdout)?; 1393 assert_eq!(forced_refresh_output["status"], "missing"); 1394 assert_eq!( 1395 forced_refresh_output["relay_summary"]["unavailable_relays"], 1396 Value::Array(vec![Value::String(unavailable_relay.clone())]) 1397 ); 1398 assert!(forced_refresh_output["published"].is_object()); 1399 1400 Ok(()) 1401 } 1402 1403 #[tokio::test(flavor = "multi_thread", worker_threads = 4)] 1404 async fn refresh_surfaces_blocked_summary_when_all_discovery_relays_are_unavailable() 1405 -> TestResult<()> { 1406 let unavailable_relay = unavailable_relay_url()?; 1407 let temp = tempfile::tempdir()?; 1408 let env_path = temp.path().join(".env"); 1409 let state_dir = temp.path().join("state"); 1410 let signer_identity_path = temp.path().join("signer.json"); 1411 let user_identity_path = temp.path().join("user.json"); 1412 let app_identity_path = temp.path().join("app.json"); 1413 1414 write_identity( 1415 &signer_identity_path, 1416 "1111111111111111111111111111111111111111111111111111111111111111", 1417 ); 1418 write_identity( 1419 &user_identity_path, 1420 "2222222222222222222222222222222222222222222222222222222222222222", 1421 ); 1422 write_identity( 1423 &app_identity_path, 1424 "3333333333333333333333333333333333333333333333333333333333333333", 1425 ); 1426 write_env_file( 1427 &env_path, 1428 &state_dir, 1429 &signer_identity_path, 1430 &user_identity_path, 1431 &app_identity_path, 1432 &[unavailable_relay.as_str()], 1433 ); 1434 1435 let refresh = run_myc(&env_path, &["discovery", "refresh-nip89"])?; 1436 assert!( 1437 !refresh.status.success(), 1438 "refresh-nip89 unexpectedly succeeded: {}", 1439 String::from_utf8_lossy(&refresh.stdout) 1440 ); 1441 let refresh_stderr = String::from_utf8_lossy(&refresh.stderr); 1442 assert!( 1443 refresh_stderr.contains("failed to fetch discovery state from all configured relays"), 1444 "unexpected refresh stderr: {refresh_stderr}" 1445 ); 1446 let attempt_id = extract_discovery_attempt_id(&refresh_stderr).expect("attempt id"); 1447 let attempt_hint = extract_discovery_attempt_hint(&refresh_stderr).expect("attempt hint"); 1448 assert_eq!( 1449 attempt_hint["attempt_id"], 1450 Value::String(attempt_id.to_owned()) 1451 ); 1452 1453 let attempt = run_myc( 1454 &env_path, 1455 &[ 1456 "audit", 1457 "discovery-repair-attempt", 1458 "--attempt-id", 1459 attempt_id, 1460 ], 1461 )?; 1462 assert!( 1463 attempt.status.success(), 1464 "discovery-repair-attempt failed: {}", 1465 String::from_utf8_lossy(&attempt.stderr) 1466 ); 1467 let attempt_output: Value = serde_json::from_slice(&attempt.stdout)?; 1468 assert_eq!( 1469 attempt_output["attempt_id"], 1470 Value::String(attempt_id.to_owned()) 1471 ); 1472 assert_eq!( 1473 attempt_output["refresh_outcome"], 1474 Value::String("unavailable".to_owned()) 1475 ); 1476 assert_eq!( 1477 attempt_output["planned_repair_relays"], 1478 Value::Array(vec![]) 1479 ); 1480 assert_eq!( 1481 attempt_output["blocked_relays"], 1482 Value::Array(vec![Value::String(unavailable_relay)]) 1483 ); 1484 assert_eq!( 1485 attempt_output["blocked_reason"], 1486 Value::String("all_relays_unavailable".to_owned()) 1487 ); 1488 assert_eq!( 1489 attempt_output["remaining_repair_relays"], 1490 Value::Array(vec![]) 1491 ); 1492 1493 Ok(()) 1494 }