myc

Self-custodial remote signer for Radroots apps
git clone https://radroots.dev/git/myc.git
Log | Files | Refs | README | LICENSE

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(&notify);
     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, &notify).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 }