commit c753a4b864ac026f8e57ec57b2da3990780850d0
parent a1cae260eb13e7344e78743e704af6bd18e5a996
Author: triesap <tyson@radroots.org>
Date: Sun, 22 Mar 2026 13:55:27 +0000
tests: cover relay provenance in discovery sync
- extend discovery CLI smoke coverage to assert relay provenance and relay summaries
- add relay-backed end-to-end proof for cross-relay divergence with matched and drifted states
- keep existing missing matched drifted and conflicted discovery flows green under the new model
- validate with cargo test --locked
Diffstat:
| M | tests/discovery_cli.rs | | | 186 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
| M | tests/nip46_e2e.rs | | | 124 | +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---- |
2 files changed, 298 insertions(+), 12 deletions(-)
diff --git a/tests/discovery_cli.rs b/tests/discovery_cli.rs
@@ -13,6 +13,7 @@ use radroots_nostr::prelude::{
RadrootsNostrApplicationHandlerSpec, RadrootsNostrClient, RadrootsNostrMetadata,
radroots_nostr_build_application_handler_event,
};
+use radroots_nostr_connect::prelude::{RadrootsNostrConnectBunkerUri, RadrootsNostrConnectUri};
use serde_json::Value;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{Mutex, Notify, mpsc, oneshot};
@@ -289,8 +290,13 @@ fn write_config(
signer_identity_path: &Path,
user_identity_path: &Path,
app_identity_path: &Path,
- relay_url: &str,
+ relay_urls: &[&str],
) {
+ let relay_list = relay_urls
+ .iter()
+ .map(|relay| format!("\"{relay}\""))
+ .collect::<Vec<_>>()
+ .join(", ");
let config = format!(
r#"[service]
instance_name = "myc"
@@ -313,8 +319,8 @@ enabled = true
domain = "signer.example.com"
handler_identifier = "myc"
app_identity_path = "{app_identity_path}"
-public_relays = ["{relay_url}"]
-publish_relays = ["{relay_url}"]
+public_relays = [{relay_list}]
+publish_relays = [{relay_list}]
nostrconnect_url_template = "https://signer.example.com/connect?uri=<nostrconnect>"
nip05_output_path = "{nip05_output_path}"
@@ -337,7 +343,7 @@ relays = []
signer_identity_path = signer_identity_path.display(),
user_identity_path = user_identity_path.display(),
app_identity_path = app_identity_path.display(),
- relay_url = relay_url,
+ relay_list = relay_list,
nip05_output_path = state_dir.join("public/.well-known/nostr.json").display(),
);
fs::write(path, config).expect("write config");
@@ -400,7 +406,7 @@ fn export_bundle_and_verify_bundle_work_through_the_cli() -> TestResult<()> {
&signer_identity_path,
&user_identity_path,
&app_identity_path,
- "wss://relay.example.com",
+ &["wss://relay.example.com"],
);
let export = run_myc(
@@ -481,7 +487,7 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> {
&signer_identity_path,
&user_identity_path,
&app_identity_path,
- relay.url(),
+ &[relay.url()],
);
let inspect_missing = run_myc(&config_path, &["discovery", "inspect-live-nip89"])?;
@@ -498,6 +504,13 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> {
.len(),
0
);
+ assert_eq!(
+ inspect_missing_output["relay_states"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?;
assert!(
@@ -524,6 +537,13 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> {
inspect_live_output["live_groups"].as_array().unwrap().len(),
1
);
+ assert_eq!(
+ inspect_live_output["live_groups"][0]["source_relays"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
let diff = run_myc(&config_path, &["discovery", "diff-live-nip89"])?;
assert!(
@@ -534,6 +554,17 @@ async fn discovery_sync_commands_work_through_the_cli() -> TestResult<()> {
let diff_output: Value = serde_json::from_slice(&diff.stdout)?;
assert_eq!(diff_output["status"], "matched");
assert_eq!(diff_output["live_groups"].as_array().unwrap().len(), 1);
+ assert_eq!(
+ diff_output["relay_summary"]["matched_relays"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ diff_output["relay_states"][0]["status"],
+ Value::String("matched".to_owned())
+ );
Ok(())
}
@@ -566,7 +597,7 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
&signer_identity_path,
&user_identity_path,
&app_identity_path,
- relay.url(),
+ &[relay.url()],
);
let mut first_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
@@ -595,6 +626,13 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
let diff_output: Value = serde_json::from_slice(&diff.stdout)?;
assert_eq!(diff_output["status"], "conflicted");
assert_eq!(diff_output["live_groups"].as_array().unwrap().len(), 2);
+ assert_eq!(
+ diff_output["relay_summary"]["conflicted_relays"]
+ .as_array()
+ .unwrap()
+ .len(),
+ 1
+ );
let refresh = run_myc(&config_path, &["discovery", "refresh-nip89"])?;
assert!(
@@ -620,3 +658,137 @@ async fn conflicted_refresh_requires_force_through_the_cli() -> TestResult<()> {
Ok(())
}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn discovery_diff_surfaces_relay_provenance_through_the_cli() -> TestResult<()> {
+ let relay_a = TestRelay::spawn().await?;
+ let relay_b = TestRelay::spawn().await?;
+ let temp = tempfile::tempdir()?;
+ let config_path = temp.path().join("config.toml");
+ let state_dir = temp.path().join("state");
+ let signer_identity_path = temp.path().join("signer.json");
+ let user_identity_path = temp.path().join("user.json");
+ let app_identity_path = temp.path().join("app.json");
+ let app_identity = RadrootsIdentity::from_secret_key_str(
+ "3333333333333333333333333333333333333333333333333333333333333333",
+ )?;
+ let signer_identity = RadrootsIdentity::from_secret_key_str(
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ )?;
+
+ write_identity(
+ &signer_identity_path,
+ "1111111111111111111111111111111111111111111111111111111111111111",
+ );
+ write_identity(
+ &user_identity_path,
+ "2222222222222222222222222222222222222222222222222222222222222222",
+ );
+ app_identity.save_json(&app_identity_path)?;
+ write_config(
+ &config_path,
+ &state_dir,
+ &signer_identity_path,
+ &user_identity_path,
+ &app_identity_path,
+ &[relay_a.url(), relay_b.url()],
+ );
+
+ let mut matched_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
+ matched_spec.identifier = Some("myc".to_owned());
+ matched_spec.relays = vec![relay_a.url().to_owned(), relay_b.url().to_owned()];
+ let bunker_uri = RadrootsNostrConnectUri::Bunker(RadrootsNostrConnectBunkerUri {
+ remote_signer_public_key: signer_identity.public_key(),
+ relays: vec![
+ relay_a.url().parse().expect("relay a url"),
+ relay_b.url().parse().expect("relay b url"),
+ ],
+ secret: None,
+ })
+ .to_string();
+ let encoded_bunker_uri: String =
+ url::form_urlencoded::byte_serialize(bunker_uri.as_bytes()).collect();
+ matched_spec.nostrconnect_url = Some(format!(
+ "https://signer.example.com/connect?uri={encoded_bunker_uri}"
+ ));
+ let mut matched_metadata = RadrootsNostrMetadata::default();
+ matched_metadata.name = Some("myc".to_owned());
+ matched_metadata.display_name = Some("Mycorrhiza".to_owned());
+ matched_metadata.about = Some("NIP-46 signer".to_owned());
+ matched_metadata.website = Some("https://signer.example.com".to_owned());
+ matched_metadata.picture = Some("https://signer.example.com/logo.png".to_owned());
+ matched_spec.metadata = Some(matched_metadata);
+ publish_handler_event(relay_a.url(), &app_identity, &matched_spec).await?;
+
+ let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
+ drifted_spec.identifier = Some("myc".to_owned());
+ drifted_spec.relays = vec!["wss://stale.example.com".to_owned()];
+ let mut drifted_metadata = RadrootsNostrMetadata::default();
+ drifted_metadata.name = Some("stale".to_owned());
+ drifted_spec.metadata = Some(drifted_metadata);
+ publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?;
+
+ relay_a
+ .wait_for_published_events_by_author(app_identity.public_key(), 1)
+ .await?;
+ relay_b
+ .wait_for_published_events_by_author(app_identity.public_key(), 1)
+ .await?;
+
+ let inspect = run_myc(&config_path, &["discovery", "inspect-live-nip89"])?;
+ assert!(
+ inspect.status.success(),
+ "inspect-live-nip89 failed: {}",
+ String::from_utf8_lossy(&inspect.stderr)
+ );
+ let inspect_output: Value = serde_json::from_slice(&inspect.stdout)?;
+ assert_eq!(inspect_output["live_groups"].as_array().unwrap().len(), 2);
+ assert_eq!(inspect_output["relay_states"].as_array().unwrap().len(), 2);
+ let group_relays = inspect_output["live_groups"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|group| {
+ group["source_relays"]
+ .as_array()
+ .unwrap()
+ .iter()
+ .map(|relay| relay.as_str().unwrap().to_owned())
+ .collect::<Vec<_>>()
+ })
+ .collect::<Vec<_>>();
+ assert!(
+ group_relays
+ .iter()
+ .any(|relays| relays == &vec![relay_a.url().to_owned()])
+ );
+ assert!(
+ group_relays
+ .iter()
+ .any(|relays| relays == &vec![relay_b.url().to_owned()])
+ );
+
+ let diff = run_myc(&config_path, &["discovery", "diff-live-nip89"])?;
+ assert!(
+ diff.status.success(),
+ "diff-live-nip89 failed: {}",
+ String::from_utf8_lossy(&diff.stderr)
+ );
+ let diff_output: Value = serde_json::from_slice(&diff.stdout)?;
+ assert_eq!(diff_output["status"], "conflicted");
+ assert_eq!(
+ diff_output["relay_summary"]["matched_relays"],
+ Value::Array(vec![Value::String(relay_a.url().to_owned())])
+ );
+ assert_eq!(
+ diff_output["relay_summary"]["drifted_relays"],
+ Value::Array(vec![Value::String(relay_b.url().to_owned())])
+ );
+ assert_eq!(
+ diff_output["relay_summary"]["conflicted_relays"],
+ Value::Array(vec![])
+ );
+ assert_eq!(diff_output["relay_states"].as_array().unwrap().len(), 2);
+
+ Ok(())
+}
diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs
@@ -5,9 +5,9 @@ use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use myc::control;
use myc::{
- MycConfig, MycConnectionApproval, MycDiscoveryLiveStatus, MycOperationAuditKind,
- MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, diff_live_nip89,
- fetch_live_nip89, publish_nip89_event, refresh_nip89,
+ MycConfig, MycConnectionApproval, MycDiscoveryContext, MycDiscoveryLiveStatus,
+ MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime,
+ diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89,
};
use nostr::filter::MatchEventOptions;
use nostr::nips::nip44;
@@ -361,6 +361,10 @@ impl MycTestRuntime {
}
fn new_with_discovery(relay_url: &str, approval: MycConnectionApproval) -> Self {
+ Self::new_with_discovery_relays(&[relay_url], approval)
+ }
+
+ fn new_with_discovery_relays(relay_urls: &[&str], approval: MycConnectionApproval) -> Self {
let temp = tempfile::tempdir().expect("tempdir");
let mut config = MycConfig::default();
config.paths.state_dir = temp.path().join("state");
@@ -370,8 +374,10 @@ impl MycTestRuntime {
config.transport.connect_timeout_secs = 1;
config.discovery.enabled = true;
config.discovery.domain = Some("signer.example.com".to_owned());
- config.discovery.public_relays = vec![relay_url.to_owned()];
- config.discovery.publish_relays = vec![relay_url.to_owned()];
+ config.discovery.public_relays =
+ relay_urls.iter().map(|relay| (*relay).to_owned()).collect();
+ config.discovery.publish_relays =
+ relay_urls.iter().map(|relay| (*relay).to_owned()).collect();
config.discovery.nostrconnect_url_template =
Some("https://signer.example.com/connect?uri=<nostrconnect>".to_owned());
config.discovery.app_identity_path = Some(temp.path().join("app.json"));
@@ -431,6 +437,24 @@ async fn publish_handler_event(
Ok(event)
}
+async fn publish_signed_event(
+ relay_url: &str,
+ identity: &RadrootsIdentity,
+ event: &Event,
+) -> TestResult<()> {
+ 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(),
+ "signed event publish did not succeed: {:?}",
+ output.failed
+ );
+ Ok(())
+}
+
fn connect_request_message(
request_id: &str,
signer_public_key: PublicKey,
@@ -1335,6 +1359,96 @@ async fn diff_live_nip89_reports_conflicted_when_live_groups_disagree() -> TestR
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+async fn diff_live_nip89_surfaces_relay_divergence_with_provenance() -> TestResult<()> {
+ let relay_a = TestRelay::spawn().await?;
+ let relay_b = TestRelay::spawn().await?;
+ let test_runtime = MycTestRuntime::new_with_discovery_relays(
+ &[relay_a.url(), relay_b.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 matched_event = MycDiscoveryContext::from_runtime(&runtime)?
+ .build_signed_handler_event()
+ .expect("matched event");
+ publish_signed_event(relay_a.url(), &app_identity, &matched_event).await?;
+
+ let mut drifted_spec = RadrootsNostrApplicationHandlerSpec::new(vec![24_133]);
+ drifted_spec.identifier = Some("myc".to_owned());
+ drifted_spec.relays = vec!["wss://stale.example.com".to_owned()];
+ let mut drifted_metadata = RadrootsNostrMetadata::default();
+ drifted_metadata.name = Some("stale".to_owned());
+ drifted_spec.metadata = Some(drifted_metadata);
+ publish_handler_event(relay_b.url(), &app_identity, &drifted_spec).await?;
+
+ relay_a
+ .wait_for_published_events_by_author(app_identity.public_key(), 1)
+ .await?;
+ relay_b
+ .wait_for_published_events_by_author(app_identity.public_key(), 1)
+ .await?;
+
+ let diff = diff_live_nip89(&runtime).await?;
+
+ assert_eq!(diff.status, MycDiscoveryLiveStatus::Conflicted);
+ assert_eq!(diff.live_groups.len(), 2);
+ assert_eq!(diff.relay_states.len(), 2);
+ assert_eq!(diff.relay_summary.total_relays, 2);
+ assert_eq!(
+ diff.relay_summary.matched_relays,
+ vec![relay_a.url().to_owned()]
+ );
+ assert_eq!(
+ diff.relay_summary.drifted_relays,
+ vec![relay_b.url().to_owned()]
+ );
+ assert!(diff.relay_summary.missing_relays.is_empty());
+ assert!(diff.relay_summary.conflicted_relays.is_empty());
+
+ let matched_relay = diff
+ .relay_states
+ .iter()
+ .find(|relay_state| relay_state.relay_url == relay_a.url())
+ .expect("matched relay");
+ assert_eq!(matched_relay.status, MycDiscoveryLiveStatus::Matched);
+ assert_eq!(matched_relay.live_groups.len(), 1);
+ assert_eq!(
+ matched_relay.live_groups[0].source_relays,
+ vec![relay_a.url().to_owned()]
+ );
+
+ let drifted_relay = diff
+ .relay_states
+ .iter()
+ .find(|relay_state| relay_state.relay_url == relay_b.url())
+ .expect("drifted relay");
+ assert_eq!(drifted_relay.status, MycDiscoveryLiveStatus::Drifted);
+ assert_eq!(drifted_relay.live_groups.len(), 1);
+ assert_eq!(
+ drifted_relay.live_groups[0].source_relays,
+ vec![relay_b.url().to_owned()]
+ );
+
+ let live_group_relays = diff
+ .live_groups
+ .iter()
+ .map(|group| group.source_relays.clone())
+ .collect::<Vec<_>>();
+ assert!(live_group_relays.contains(&vec![relay_a.url().to_owned()]));
+ assert!(live_group_relays.contains(&vec![relay_b.url().to_owned()]));
+
+ Ok(())
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn refresh_nip89_requires_force_when_live_handler_is_conflicted() -> TestResult<()> {
let relay = TestRelay::spawn().await?;
let test_runtime =