myc

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

commit 804c37619c7bae69474c03803ee5adc5da61202c
parent 3fd36213bb5863d026b35b914e51a309dd821037
Author: triesap <tyson@radroots.org>
Date:   Thu, 26 Mar 2026 17:00:30 +0000

tests: add sqlite persistence parity coverage

Diffstat:
Mtests/nip46_e2e.rs | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Atests/persistence_cli.rs | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 270 insertions(+), 2 deletions(-)

diff --git a/tests/nip46_e2e.rs b/tests/nip46_e2e.rs @@ -8,8 +8,9 @@ use myc::control; use myc::{ MycConfig, MycConnectionApproval, MycDiscoveryContext, MycDiscoveryLiveStatus, MycDiscoveryRelayFetchStatus, MycDiscoveryRepairOutcome, MycOperationAuditKind, - MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, MycTransportDeliveryPolicy, - diff_live_nip89, fetch_live_nip89, publish_nip89_event, refresh_nip89, + MycOperationAuditOutcome, MycOperationAuditRecord, MycRuntime, MycRuntimeAuditBackend, + MycSignerStateBackend, MycTransportDeliveryPolicy, diff_live_nip89, fetch_live_nip89, + publish_nip89_event, refresh_nip89, }; use nostr::filter::MatchEventOptions; use nostr::nips::nip44; @@ -1397,6 +1398,144 @@ async fn live_listener_consumes_connect_secret_only_after_successful_publish() - } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn live_listener_works_with_sqlite_signer_state_and_runtime_audit() -> TestResult<()> { + let relay = TestRelay::spawn().await?; + let test_runtime = MycTestRuntime::new_with_transport_config( + &[relay.url()], + MycConnectionApproval::NotRequired, + |config| { + config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; + }, + ); + let runtime = test_runtime.runtime.clone(); + let signer_public_key = runtime.signer_identity().public_key(); + let client_identity = + identity("5353535353535353535353535353535353535353535353535353535353535353"); + let base_created_at = Timestamp::now().as_secs(); + + assert_eq!( + runtime + .paths() + .signer_state_path + .file_name() + .and_then(|name| name.to_str()), + Some("signer-state.sqlite") + ); + assert_eq!( + runtime + .paths() + .runtime_audit_path + .file_name() + .and_then(|name| name.to_str()), + Some("operations.sqlite") + ); + + relay + .queue_publish_outcomes(signer_public_key, &[false, true]) + .await; + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let service_runtime = runtime.clone(); + let listener_task = tokio::spawn(async move { + service_runtime + .run_until(async { + let _ = shutdown_rx.await; + }) + .await + }); + + relay.wait_for_subscription_count(1).await?; + + let request_one = build_request_event( + &client_identity, + signer_public_key, + connect_request_message("sqlite-connect-1", signer_public_key, "sqlite-secret"), + base_created_at, + ); + publish_event(relay.url(), &request_one).await?; + wait_for_connection_count(&runtime, 1).await?; + sleep(Duration::from_millis(100)).await; + + assert!( + relay + .published_events_by_author(signer_public_key) + .await + .is_empty() + ); + let initial_connection = runtime + .signer_manager()? + .list_connections()? + .into_iter() + .next() + .expect("stored connection"); + assert!(!initial_connection.connect_secret_is_consumed()); + + let request_two = build_request_event( + &client_identity, + signer_public_key, + connect_request_message("sqlite-connect-2", signer_public_key, "sqlite-secret"), + base_created_at + 1, + ); + publish_event(relay.url(), &request_two).await?; + + let response_events = relay + .wait_for_published_events_by_author(signer_public_key, 1) + .await?; + let response = decrypt_response(&client_identity, signer_public_key, &response_events[0]); + assert_eq!(response.id, "sqlite-connect-2"); + assert_eq!( + response.result, + Some(serde_json::Value::String("sqlite-secret".to_owned())) + ); + + wait_for_connect_secret_consumed(&runtime).await?; + let consumed_connection = runtime + .signer_manager()? + .list_connections()? + .into_iter() + .next() + .expect("stored connection"); + assert!(consumed_connection.connect_secret_is_consumed()); + let operation_audit = runtime.operation_audit_store().list_all()?; + assert_eq!(operation_audit.len(), 2); + assert_eq!( + operation_audit[0].outcome, + MycOperationAuditOutcome::Rejected + ); + assert_eq!( + operation_audit[1].outcome, + MycOperationAuditOutcome::Succeeded + ); + + let restarted_runtime = MycRuntime::bootstrap(runtime.config().clone())?; + assert_eq!( + restarted_runtime + .signer_manager()? + .list_connections()? + .len(), + 1 + ); + assert_eq!( + restarted_runtime.operation_audit_store().list_all()?.len(), + 2 + ); + assert!( + restarted_runtime + .signer_manager()? + .list_connections()? + .into_iter() + .next() + .expect("persisted connection") + .connect_secret_is_consumed() + ); + + let _ = shutdown_tx.send(()); + listener_task.await??; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn trusted_client_reauths_after_authorized_ttl() -> TestResult<()> { let relay = TestRelay::spawn().await?; let client_identity = diff --git a/tests/persistence_cli.rs b/tests/persistence_cli.rs @@ -0,0 +1,129 @@ +use std::path::Path; +use std::process::Command; + +use myc::{ + MycConfig, MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, + MycRuntime, MycRuntimeAuditBackend, MycSignerStateBackend, +}; +use nostr::PublicKey; +use radroots_identity::RadrootsIdentity; +use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft; +use serde_json::Value; + +fn write_identity(path: &Path, secret_key: &str) { + RadrootsIdentity::from_secret_key_str(secret_key) + .expect("identity") + .save_json(path) + .expect("save identity"); +} + +fn bootstrap_populated_json_runtime(temp: &tempfile::TempDir) -> (MycConfig, MycConfig) { + let mut json_config = MycConfig::default(); + json_config.paths.state_dir = temp.path().join("state"); + json_config.paths.signer_identity_path = temp.path().join("signer.json"); + json_config.paths.user_identity_path = temp.path().join("user.json"); + + write_identity( + &json_config.paths.signer_identity_path, + "1111111111111111111111111111111111111111111111111111111111111111", + ); + write_identity( + &json_config.paths.user_identity_path, + "2222222222222222222222222222222222222222222222222222222222222222", + ); + + let runtime = MycRuntime::bootstrap(json_config.clone()).expect("json runtime"); + let manager = runtime.signer_manager().expect("manager"); + let connection = manager + .register_connection(RadrootsNostrSignerConnectionDraft::new( + PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") + .expect("pubkey"), + runtime.user_public_identity(), + )) + .expect("register connection"); + runtime.record_operation_audit(&MycOperationAuditRecord::new( + MycOperationAuditKind::ListenerResponsePublish, + MycOperationAuditOutcome::Succeeded, + Some(&connection.connection_id), + Some("request-1"), + 1, + 1, + "publish succeeded", + )); + + let mut sqlite_config = json_config.clone(); + sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; + sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; + + (json_config, sqlite_config) +} + +#[test] +fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() { + let temp = tempfile::tempdir().expect("tempdir"); + let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(&temp); + let env_path = temp.path().join("myc-sqlite.env"); + std::fs::write( + &env_path, + sqlite_config.to_env_string().expect("render sqlite env"), + ) + .expect("write env"); + + let output = Command::new(env!("CARGO_BIN_EXE_myc")) + .arg("--env-file") + .arg(&env_path) + .arg("persistence") + .arg("import-json-to-sqlite") + .output() + .expect("run import"); + + assert!(output.status.success(), "{:?}", output); + + let parsed: Value = serde_json::from_slice(&output.stdout).expect("import json"); + assert_eq!(parsed["signer_state"]["connection_count"], 1); + assert_eq!(parsed["signer_state"]["request_audit_count"], 0); + assert_eq!(parsed["runtime_audit"]["record_count"], 1); + assert!( + parsed["signer_state"]["destination_path"] + .as_str() + .expect("sqlite signer destination") + .ends_with("signer-state.sqlite") + ); + assert!( + parsed["runtime_audit"]["destination_path"] + .as_str() + .expect("sqlite audit destination") + .ends_with("operations.sqlite") + ); + + let sqlite_runtime = MycRuntime::bootstrap(sqlite_config.clone()).expect("sqlite runtime"); + assert_eq!( + sqlite_runtime + .signer_manager() + .expect("sqlite manager") + .list_connections() + .expect("sqlite connections") + .len(), + 1 + ); + assert_eq!( + sqlite_runtime + .operation_audit_store() + .list_all() + .expect("sqlite audit records") + .len(), + 1 + ); + + let rerun = Command::new(env!("CARGO_BIN_EXE_myc")) + .arg("--env-file") + .arg(&env_path) + .arg("persistence") + .arg("import-json-to-sqlite") + .output() + .expect("rerun import"); + + assert!(!rerun.status.success(), "{:?}", rerun); + let stderr = String::from_utf8(rerun.stderr).expect("rerun stderr"); + assert!(stderr.contains("sqlite signer-state destination")); +}