myc

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

commit 697eb586ecff1a30f755bdc0ec4b631440547410
parent 48a89feb288bb9596ee53c03f9b68acb5f21c2a8
Author: triesap <tyson@radroots.org>
Date:   Sun, 22 Mar 2026 13:28:01 +0000

tests: add relay-backed discovery sync coverage

- replay stored events on relay subscriptions so live fetch paths can be exercised in process
- cover missing matched and drifted live handler states through fetch diff and refresh flows
- assert compare skip and publish audit behavior for the new discovery sync lifecycle
- validate with cargo test --locked --test nip46_e2e and cargo test --locked

Diffstat:
Mtests/nip46_e2e.rs | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 241 insertions(+), 5 deletions(-)

diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -5,8 +5,9 @@ use std::time::Duration; use futures_util::{SinkExt, StreamExt}; use myc::control; use myc::{ - MycConfig, MycConnectionApproval, MycOperationAuditKind, MycOperationAuditOutcome, - MycOperationAuditRecord, MycRuntime, publish_nip89_event, + MycConfig, MycConnectionApproval, MycDiscoveryLiveStatus, MycOperationAuditKind, + MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, diff_live_nip89, + fetch_live_nip89, publish_nip89_event, refresh_nip89, }; use nostr::filter::MatchEventOptions; use nostr::nips::nip44; @@ -16,6 +17,10 @@ use nostr::{ SecretKey, SubscriptionId, Tag, Timestamp, }; use radroots_identity::RadrootsIdentity; +use radroots_nostr::prelude::{ + RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrMetadata, + radroots_nostr_build_application_handler_event, +}; use radroots_nostr_connect::prelude::{ RADROOTS_NOSTR_CONNECT_RPC_KIND, RadrootsNostrConnectClientMetadata, RadrootsNostrConnectClientUri, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, @@ -210,9 +215,18 @@ async fn handle_client_message( subscription_id, filters, } => { - let eose = RelayMessage::eose(subscription_id.as_ref().clone()).as_json(); - let sender = { + let (sender, matching_events) = { let mut state = state.lock().await; + let matching_events = state + .published_events + .iter() + .filter(|event| { + filters + .iter() + .any(|filter| filter.match_event(event, MatchEventOptions::new())) + }) + .cloned() + .collect::<Vec<_>>(); state.subscriptions.push(RelaySubscription { connection_id, subscription_id: subscription_id.as_ref().clone(), @@ -222,9 +236,15 @@ async fn handle_client_message( .collect(), }); notify.notify_waiters(); - state.senders.get(&connection_id).cloned() + (state.senders.get(&connection_id).cloned(), matching_events) }; if let Some(sender) = sender { + for event in matching_events { + let message = + RelayMessage::event(subscription_id.as_ref().clone(), event).as_json(); + let _ = sender.send(Message::Text(message.into())); + } + let eose = RelayMessage::eose(subscription_id.as_ref().clone()).as_json(); let _ = sender.send(Message::Text(eose.into())); } } @@ -390,6 +410,27 @@ fn identity(secret_key: &str) -> RadrootsIdentity { RadrootsIdentity::from_secret_key_str(secret_key).expect("identity") } +async fn publish_handler_event( + relay_url: &str, + identity: &RadrootsIdentity, + spec: &RadrootsNostrApplicationHandlerSpec, +) -> TestResult<Event> { + let event = radroots_nostr_build_application_handler_event(spec)? + .sign_with_keys(identity.keys()) + .map_err(|error| format!("failed to sign handler event: {error}"))?; + let client = RadrootsNostrClient::from_identity(identity); + let _ = client.add_relay(relay_url).await?; + client.connect().await; + client.wait_for_connection(Duration::from_secs(1)).await; + let output = client.send_event(&event).await?; + assert!( + !output.success.is_empty(), + "handler event publish did not succeed: {:?}", + output.failed + ); + Ok(event) +} + fn connect_request_message( request_id: &str, signer_public_key: PublicKey, @@ -1052,3 +1093,198 @@ async fn explicit_nip89_publish_retries_cleanly_after_rejection() -> TestResult< Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn fetch_live_nip89_reports_missing_when_handler_is_unpublished() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = + MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); + + let output = fetch_live_nip89(&test_runtime.runtime).await?; + + assert_eq!(output.handler_identifier, "myc"); + assert_eq!(output.publish_relays, vec![relay.url().to_owned()]); + assert!(output.live_event.is_none()); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn diff_live_nip89_reports_matched_after_publish() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = + MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); + let runtime = test_runtime.runtime; + let app_identity = RadrootsIdentity::load_from_path_auto( + runtime + .config() + .discovery + .app_identity_path + .as_ref() + .expect("app identity path"), + )?; + + relay + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + let published = publish_nip89_event(&runtime).await?; + relay + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + + let diff = diff_live_nip89(&runtime).await?; + + assert_eq!(diff.status, MycDiscoveryLiveStatus::Matched); + assert!(diff.differing_fields.is_empty()); + let live_event = diff.live_event.expect("live event"); + assert_eq!(live_event.event_id_hex, published.event.id.to_hex()); + assert_eq!( + live_event.handler.author_public_key_hex, + app_identity.public_key_hex() + ); + assert_eq!(live_event.handler.kinds, vec![24_133]); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_nip89_publishes_when_live_handler_is_missing() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = + MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); + let runtime = test_runtime.runtime; + let app_identity = RadrootsIdentity::load_from_path_auto( + runtime + .config() + .discovery + .app_identity_path + .as_ref() + .expect("app identity path"), + )?; + + relay + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + + let refreshed = refresh_nip89(&runtime, false).await?; + + assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Missing); + assert_eq!(refreshed.differing_fields, vec!["live_event".to_owned()]); + assert!(refreshed.live_event.is_none()); + assert!(refreshed.published.is_some()); + + let audit = wait_for_operation_audit_count(&runtime, 2).await?; + assert_eq!( + audit[0].operation, + MycOperationAuditKind::DiscoveryHandlerCompare + ); + assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Missing); + assert_eq!( + audit[1].operation, + MycOperationAuditKind::DiscoveryHandlerPublish + ); + assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_nip89_skips_when_live_handler_matches() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = + MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); + let runtime = test_runtime.runtime; + let app_identity = RadrootsIdentity::load_from_path_auto( + runtime + .config() + .discovery + .app_identity_path + .as_ref() + .expect("app identity path"), + )?; + + relay + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + publish_nip89_event(&runtime).await?; + relay + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + + let refreshed = refresh_nip89(&runtime, false).await?; + + assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Matched); + assert!(refreshed.differing_fields.is_empty()); + assert!(refreshed.published.is_none()); + + let audit = wait_for_operation_audit_count(&runtime, 3).await?; + assert_eq!( + audit[1].operation, + MycOperationAuditKind::DiscoveryHandlerCompare + ); + assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Matched); + assert_eq!( + audit[2].operation, + MycOperationAuditKind::DiscoveryHandlerRefresh + ); + assert_eq!(audit[2].outcome, MycOperationAuditOutcome::Skipped); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn refresh_nip89_republishes_when_live_handler_drifted() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = + MycTestRuntime::new_with_discovery(relay.url(), MycConnectionApproval::ExplicitUser); + let runtime = test_runtime.runtime; + let app_identity = RadrootsIdentity::load_from_path_auto( + runtime + .config() + .discovery + .app_identity_path + .as_ref() + .expect("app identity path"), + )?; + + let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]); + drifted_spec.identifier = Some("myc".to_owned()); + drifted_spec.relays = vec!["wss://wrong.example.com".to_owned()]; + drifted_spec.nostrconnect_url = + Some("https://wrong.example.com/connect?uri=nostrconnect%3A%2F%2Fstale".to_owned()); + let mut metadata = RadrootsNostrMetadata::default(); + metadata.name = Some("stale".to_owned()); + drifted_spec.metadata = Some(metadata); + publish_handler_event(relay.url(), &app_identity, &drifted_spec).await?; + relay + .wait_for_published_events_by_author(app_identity.public_key(), 1) + .await?; + + relay + .queue_publish_outcomes(app_identity.public_key(), &[true]) + .await; + let refreshed = refresh_nip89(&runtime, false).await?; + + assert_eq!(refreshed.status, MycDiscoveryLiveStatus::Drifted); + assert!(refreshed.published.is_some()); + assert!( + refreshed + .differing_fields + .iter() + .any(|field| field == "relays" || field == "nostrconnect_url" || field == "metadata") + ); + + let audit = wait_for_operation_audit_count(&runtime, 2).await?; + assert_eq!( + audit[0].operation, + MycOperationAuditKind::DiscoveryHandlerCompare + ); + assert_eq!(audit[0].outcome, MycOperationAuditOutcome::Drifted); + assert_eq!( + audit[1].operation, + MycOperationAuditKind::DiscoveryHandlerPublish + ); + assert_eq!(audit[1].outcome, MycOperationAuditOutcome::Succeeded); + + Ok(()) +}