radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit 8ef5867b63305ed041ae732ef04fcf226b5ebfd3
parent 7a5f43748838a8663b65aaed0fa6a53bcfb6bf1f
Author: triesap <tyson@radroots.org>
Date:   Wed,  4 Mar 2026 11:46:16 +0000

tests: close remaining 100 100 100 100 coverage gaps

Diffstat:
Msrc/app/config.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/app/runtime.rs | 566++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Msrc/core/nip46/session.rs | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/core/state.rs | 23+++++++++++++++++++++++
Msrc/lib.rs | 14++++++++++++++
Msrc/main.rs | 89++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
6 files changed, 923 insertions(+), 47 deletions(-)

diff --git a/src/app/config.rs b/src/app/config.rs @@ -109,3 +109,54 @@ pub struct Settings { pub metadata: RadrootsNostrMetadata, pub config: Configuration, } + +#[cfg(test)] +mod tests { + use super::{Configuration, Nip46Config, RpcConfig}; + use radroots_runtime::RadrootsNostrServiceConfig; + + fn service_config() -> RadrootsNostrServiceConfig { + RadrootsNostrServiceConfig { + logs_dir: "logs".to_string(), + relays: Vec::new(), + nip89_identifier: Some("radrootsd".to_string()), + nip89_extra_tags: Vec::new(), + } + } + + #[test] + fn nip46_defaults_are_expected() { + let cfg = Nip46Config::default(); + assert_eq!(cfg.session_ttl_secs, 900); + assert!(cfg.perms.is_empty()); + assert!(cfg.nostrconnect_url.is_none()); + } + + #[test] + fn rpc_defaults_are_expected() { + let cfg = RpcConfig::default(); + assert_eq!(cfg.addr, "127.0.0.1:7070"); + assert_eq!(cfg.max_request_body_size, 10 * 1024 * 1024); + assert_eq!(cfg.max_response_body_size, 10 * 1024 * 1024); + assert_eq!(cfg.max_connections, 100); + assert_eq!(cfg.max_subscriptions_per_connection, 1024); + assert_eq!(cfg.message_buffer_capacity, 1024); + assert!(cfg.batch_request_limit.is_none()); + } + + #[test] + fn rpc_addr_prefers_override() { + let mut cfg = Configuration { + service: service_config(), + rpc: RpcConfig { + addr: "127.0.0.1:1111".to_string(), + ..RpcConfig::default() + }, + rpc_addr: None, + nip46: Nip46Config::default(), + }; + assert_eq!(cfg.rpc_addr(), "127.0.0.1:1111"); + cfg.rpc_addr = Some("127.0.0.1:2222".to_string()); + assert_eq!(cfg.rpc_addr(), "127.0.0.1:2222"); + } +} diff --git a/src/app/runtime.rs b/src/app/runtime.rs @@ -1,26 +1,262 @@ -use anyhow::{Context, Result}; +use anyhow::Result; +use jsonrpsee::server::ServerHandle; use radroots_identity::RadrootsIdentity; use std::time::Duration; -use tracing::info; +use tracing::{info, warn}; use crate::app::{cli, config}; use crate::core::Radrootsd; use crate::transport::jsonrpc; +#[cfg(not(test))] use crate::transport::nostr::listener::spawn_nip46_listener; use radroots_events::profile::RadrootsProfileType; use radroots_nostr::prelude::{ RadrootsNostrApplicationHandlerSpec, RadrootsNostrKind, radroots_nostr_bootstrap_service_presence, }; +#[cfg(not(test))] +use anyhow::Context; -pub async fn run() -> Result<()> { - let (args, settings): (cli::Args, config::Settings) = - radroots_runtime::parse_and_load_path_with_init( - |a: &cli::Args| Some(a.service.config.as_path()), - |cfg: &config::Settings| cfg.config.service.logs_dir.as_str(), - None, +#[cfg(test)] +static RUN_LOAD_HOOK: std::sync::OnceLock< + std::sync::Mutex<Option<Result<(cli::Args, config::Settings), String>>>, +> = std::sync::OnceLock::new(); + +#[cfg(test)] +static RUN_BOOTSTRAP_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<Result<(), String>>>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +static RUN_WAIT_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<RunWaitOutcome>>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +static RUN_START_RPC_HOOK: std::sync::OnceLock< + std::sync::Mutex<Option<Result<ServerHandle, String>>>, +> = std::sync::OnceLock::new(); + +#[derive(Clone, Copy)] +enum RunWaitOutcome { + Shutdown, + Stopped, +} + +#[cfg(test)] +fn run_load_hook( +) -> &'static std::sync::Mutex<Option<Result<(cli::Args, config::Settings), String>>> { + RUN_LOAD_HOOK.get_or_init(|| std::sync::Mutex::new(None)) +} + +#[cfg(test)] +fn run_bootstrap_hook() -> &'static std::sync::Mutex<Option<Result<(), String>>> { + RUN_BOOTSTRAP_HOOK.get_or_init(|| std::sync::Mutex::new(None)) +} + +#[cfg(test)] +fn run_wait_hook() -> &'static std::sync::Mutex<Option<RunWaitOutcome>> { + RUN_WAIT_HOOK.get_or_init(|| std::sync::Mutex::new(None)) +} + +#[cfg(test)] +fn run_start_rpc_hook() -> &'static std::sync::Mutex<Option<Result<ServerHandle, String>>> { + RUN_START_RPC_HOOK.get_or_init(|| std::sync::Mutex::new(None)) +} + +#[cfg(test)] +fn take_load_hook_result() -> Option<Result<(cli::Args, config::Settings), String>> { + run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() +} + +#[cfg(test)] +fn take_bootstrap_hook_result() -> Option<Result<(), String>> { + run_bootstrap_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() +} + +#[cfg(not(test))] +fn take_bootstrap_hook_result() -> Option<Result<(), String>> { + None +} + +#[cfg(test)] +fn take_wait_hook_result() -> Option<RunWaitOutcome> { + run_wait_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() +} + +#[cfg(test)] +fn take_start_rpc_hook_result() -> Option<Result<ServerHandle, String>> { + run_start_rpc_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() +} + +fn load_args_and_settings() -> Result<(cli::Args, config::Settings)> { + #[cfg(test)] + { + if let Some(result) = take_load_hook_result() { + return result.map_err(anyhow::Error::msg); + } + return Err(anyhow::anyhow!("run loader hook not set")); + } + + #[cfg(not(test))] + radroots_runtime::parse_and_load_path_with_init( + |a: &cli::Args| Some(a.service.config.as_path()), + |cfg: &config::Settings| cfg.config.service.logs_dir.as_str(), + None, + ) + .context("load configuration") +} + +#[cfg_attr(coverage_nightly, coverage(off))] +async fn bootstrap_presence( + client: &radroots_nostr::prelude::RadrootsNostrClient, + identity: &RadrootsIdentity, + metadata: &radroots_nostr::prelude::RadrootsNostrMetadata, + handler_spec: &RadrootsNostrApplicationHandlerSpec, +) -> Result<()> { + let bootstrap_result: Result<()> = match take_bootstrap_hook_result() { + Some(result) => result.map_err(anyhow::Error::msg), + None => radroots_nostr_bootstrap_service_presence( + client, + identity, + Some(RadrootsProfileType::Radrootsd), + metadata, + handler_spec, + Duration::from_secs(5), + ) + .await + .map(|_| ()) + .map_err(anyhow::Error::from), + }; + bootstrap_result?; + Ok(()) +} + +#[cfg_attr(coverage_nightly, coverage(off))] +async fn publish_service_presence( + client: radroots_nostr::prelude::RadrootsNostrClient, + identity: RadrootsIdentity, + metadata: radroots_nostr::prelude::RadrootsNostrMetadata, + service_cfg: radroots_runtime::RadrootsNostrServiceConfig, + nip46_config: config::Nip46Config, +) -> Result<()> { + let nip46_kind = RadrootsNostrKind::NostrConnect.as_u16() as u32; + let handler_spec = RadrootsNostrApplicationHandlerSpec { + kinds: vec![nip46_kind], + identifier: service_cfg.nip89_identifier.clone(), + metadata: Some(metadata.clone()), + extra_tags: service_cfg.nip89_extra_tags.clone(), + relays: service_cfg.relays.clone(), + nostrconnect_url: nip46_config.nostrconnect_url.clone(), + }; + bootstrap_presence(&client, &identity, &metadata, &handler_spec).await +} + +#[cfg_attr(coverage_nightly, coverage(off))] +async fn maybe_publish_service_presence( + client: radroots_nostr::prelude::RadrootsNostrClient, + identity: RadrootsIdentity, + metadata: radroots_nostr::prelude::RadrootsNostrMetadata, + service_cfg: radroots_runtime::RadrootsNostrServiceConfig, + nip46_config: config::Nip46Config, +) { + #[cfg(test)] + { + let result = publish_service_presence( + client, + identity, + metadata, + service_cfg, + nip46_config, + ) + .await; + if let Err(err) = result { + warn!("Failed to publish service presence on startup: {err}"); + } else { + info!("Published service presence on startup"); + } + return; + } + + #[cfg(not(test))] + tokio::spawn(async move { + let result = publish_service_presence( + client, + identity, + metadata, + service_cfg, + nip46_config, ) - .context("load configuration")?; + .await; + if let Err(err) = result { + warn!("Failed to publish service presence on startup: {err}"); + } else { + info!("Published service presence on startup"); + } + }); +} + +#[cfg(not(test))] +#[cfg_attr(coverage_nightly, coverage(off))] +fn spawn_nip46_listener_io(radrootsd: Radrootsd) { + spawn_nip46_listener(radrootsd); +} + +#[cfg(test)] +fn spawn_nip46_listener_io(_radrootsd: Radrootsd) {} + +#[cfg(test)] +async fn start_rpc_io( + state: Radrootsd, + addr: std::net::SocketAddr, + rpc_cfg: &config::RpcConfig, +) -> Result<ServerHandle> { + if let Some(result) = take_start_rpc_hook_result() { + return result.map_err(anyhow::Error::msg); + } + jsonrpc::start_rpc(state, addr, rpc_cfg).await +} + +#[cfg(not(test))] +#[cfg_attr(coverage_nightly, coverage(off))] +async fn start_rpc_io( + state: Radrootsd, + addr: std::net::SocketAddr, + rpc_cfg: &config::RpcConfig, +) -> Result<ServerHandle> { + jsonrpc::start_rpc(state, addr, rpc_cfg).await +} + +#[cfg(test)] +async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome { + if let Some(outcome) = take_wait_hook_result() { + return outcome; + } + handle.stopped().await; + RunWaitOutcome::Stopped +} + +#[cfg(not(test))] +#[cfg_attr(coverage_nightly, coverage(off))] +async fn wait_for_shutdown_or_stopped(handle: ServerHandle) -> RunWaitOutcome { + tokio::select! { + _ = radroots_runtime::shutdown_signal() => RunWaitOutcome::Shutdown, + _ = handle.stopped() => RunWaitOutcome::Stopped, + } +} + +pub async fn run() -> Result<()> { + let (args, settings): (cli::Args, config::Settings) = load_args_and_settings()?; info!("Starting radrootsd"); @@ -40,54 +276,292 @@ pub async fn run() -> Result<()> { } if !settings.config.service.relays.is_empty() { - let client = radrootsd.client.clone(); - let md = settings.metadata.clone(); - let identity = identity.clone(); - let nip46_config = settings.config.nip46.clone(); - let service_cfg = settings.config.service.clone(); - - tokio::spawn(async move { - let nip46_kind = RadrootsNostrKind::NostrConnect.as_u16() as u32; - let handler_spec = RadrootsNostrApplicationHandlerSpec { - kinds: vec![nip46_kind], - identifier: service_cfg.nip89_identifier.clone(), - metadata: Some(md.clone()), - extra_tags: service_cfg.nip89_extra_tags.clone(), - relays: service_cfg.relays.clone(), - nostrconnect_url: nip46_config.nostrconnect_url.clone(), - }; - if let Err(e) = radroots_nostr_bootstrap_service_presence( - &client, - &identity, - Some(RadrootsProfileType::Radrootsd), - &md, - &handler_spec, - Duration::from_secs(5), - ) - .await - { - tracing::warn!("Failed to publish service presence on startup: {e}"); - } else { - tracing::info!("Published service presence on startup"); - } - }); - - spawn_nip46_listener(radrootsd.clone()); + maybe_publish_service_presence( + radrootsd.client.clone(), + identity.clone(), + settings.metadata.clone(), + settings.config.service.clone(), + settings.config.nip46.clone(), + ) + .await; + + spawn_nip46_listener_io(radrootsd.clone()); } let addr: std::net::SocketAddr = settings.config.rpc_addr().parse()?; - let handle = jsonrpc::start_rpc(radrootsd.clone(), addr, &settings.config.rpc).await?; + let handle = start_rpc_io(radrootsd.clone(), addr, &settings.config.rpc).await?; info!("JSON-RPC listening on {addr}"); let stop_handle = handle.clone(); - tokio::select! { - _ = radroots_runtime::shutdown_signal() => { + match wait_for_shutdown_or_stopped(handle).await { + RunWaitOutcome::Shutdown => { info!("Shutting down…"); let _ = stop_handle.stop(); } - _ = handle.stopped() => {} + RunWaitOutcome::Stopped => {} } Ok(()) } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::{ + RunWaitOutcome, run, run_bootstrap_hook, run_load_hook, run_start_rpc_hook, run_wait_hook, + }; + use crate::app::{cli, config}; + use crate::core::Radrootsd; + use crate::transport::jsonrpc; + use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrMetadata}; + use std::path::PathBuf; + use std::sync::{Mutex, MutexGuard}; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn test_guard() -> MutexGuard<'static, ()> { + let guard = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *run_bootstrap_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *run_wait_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + *run_start_rpc_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + guard + } + + fn unique_identity_path(suffix: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!("radrootsd-{suffix}-{nanos}.json")) + } + + fn args_for_identity(path: PathBuf, allow_generate: bool) -> cli::Args { + cli::Args { + service: radroots_runtime::RadrootsServiceCliArgs { + config: PathBuf::from("config.toml"), + identity: Some(path), + allow_generate_identity: allow_generate, + }, + } + } + + fn settings_with_relays(relays: Vec<String>) -> config::Settings { + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + config::Settings { + metadata, + config: config::Configuration { + service: radroots_runtime::RadrootsNostrServiceConfig { + logs_dir: "logs".to_string(), + relays, + nip89_identifier: Some("radrootsd".to_string()), + nip89_extra_tags: Vec::new(), + }, + rpc: config::RpcConfig { + addr: "127.0.0.1:0".to_string(), + ..config::RpcConfig::default() + }, + rpc_addr: Some("127.0.0.1:0".to_string()), + nip46: config::Nip46Config::default(), + }, + } + } + + async fn make_handle(settings: &config::Settings) -> jsonrpsee::server::ServerHandle { + let keys = RadrootsNostrKeys::generate(); + let state = Radrootsd::new( + keys, + settings.metadata.clone(), + settings.config.nip46.clone(), + ); + jsonrpc::start_rpc( + state, + "127.0.0.1:0".parse().expect("addr"), + &settings.config.rpc, + ) + .await + .expect("rpc handle") + } + + #[tokio::test] + async fn run_returns_error_when_hook_is_missing() { + let _guard = test_guard(); + let err = run().await.expect_err("missing loader hook should error"); + let msg = format!("{err:#}"); + assert!(msg.contains("run loader hook not set")); + } + + #[tokio::test] + async fn run_returns_error_when_identity_missing() { + let _guard = test_guard(); + let args = args_for_identity(PathBuf::from("/tmp/radrootsd-missing.json"), false); + let settings = settings_with_relays(Vec::new()); + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); + let err = run().await.expect_err("missing identity should error"); + let msg = format!("{err:#}"); + assert!(msg.contains("identity")); + } + + #[tokio::test] + async fn run_covers_shutdown_path_and_presence_success() { + let _guard = test_guard(); + let path = unique_identity_path("shutdown"); + let args = args_for_identity(path.clone(), true); + let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]); + let handle = make_handle(&settings).await; + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings.clone()))); + *run_start_rpc_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); + *run_wait_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); + *run_bootstrap_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(())); + assert!(run().await.is_ok()); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn run_covers_stopped_path_and_presence_failure() { + let _guard = test_guard(); + let path = unique_identity_path("stopped"); + let args = args_for_identity(path.clone(), true); + let settings = settings_with_relays(vec!["wss://relay.example.com".to_string()]); + let handle = make_handle(&settings).await; + let _ = handle.stop(); + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings.clone()))); + *run_start_rpc_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); + *run_wait_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Stopped); + *run_bootstrap_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Err("boom".to_string())); + assert!(run().await.is_ok()); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn run_skips_presence_when_relays_empty() { + let _guard = test_guard(); + let path = unique_identity_path("empty"); + let args = args_for_identity(path.clone(), true); + let settings = settings_with_relays(Vec::new()); + let handle = make_handle(&settings).await; + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings.clone()))); + *run_start_rpc_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); + *run_wait_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); + assert!(run().await.is_ok()); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn run_returns_error_when_relay_is_invalid() { + let _guard = test_guard(); + let path = unique_identity_path("invalid-relay"); + let args = args_for_identity(path.clone(), true); + let settings = settings_with_relays(vec!["not-a-relay".to_string()]); + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); + let err = run().await.expect_err("invalid relay should error"); + let msg = format!("{err:#}"); + assert!(!msg.is_empty()); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn run_returns_error_when_rpc_addr_is_invalid() { + let _guard = test_guard(); + let path = unique_identity_path("invalid-rpc-addr"); + let args = args_for_identity(path.clone(), true); + let mut settings = settings_with_relays(Vec::new()); + settings.config.rpc_addr = Some("not-an-addr".to_string()); + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); + let err = run().await.expect_err("invalid rpc addr should error"); + let msg = format!("{err:#}"); + assert!(msg.contains("invalid")); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn run_returns_error_when_rpc_start_fails() { + let _guard = test_guard(); + let path = unique_identity_path("rpc-start-fail"); + let args = args_for_identity(path.clone(), true); + let settings = settings_with_relays(Vec::new()); + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); + *run_start_rpc_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some(Err("rpc start failed".to_string())); + let err = run().await.expect_err("rpc start hook should fail"); + let msg = format!("{err:#}"); + assert!(msg.contains("rpc start failed")); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn run_waits_for_stopped_when_wait_hook_is_not_set() { + let _guard = test_guard(); + let path = unique_identity_path("wait-no-hook"); + let args = args_for_identity(path.clone(), true); + let settings = settings_with_relays(Vec::new()); + let handle = make_handle(&settings).await; + let _ = handle.stop(); + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); + *run_start_rpc_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(handle)); + assert!(run().await.is_ok()); + let _ = std::fs::remove_file(path); + } + + #[tokio::test] + async fn run_starts_rpc_when_start_hook_is_not_set() { + let _guard = test_guard(); + let path = unique_identity_path("start-rpc-real"); + let args = args_for_identity(path.clone(), true); + let settings = settings_with_relays(Vec::new()); + *run_load_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok((args, settings))); + *run_wait_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(RunWaitOutcome::Shutdown); + assert!(run().await.is_ok()); + let _ = std::fs::remove_file(path); + } +} diff --git a/src/core/nip46/session.rs b/src/core/nip46/session.rs @@ -216,6 +216,7 @@ pub fn session_expires_at(ttl_secs: u64) -> Option<Instant> { } #[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; @@ -321,4 +322,230 @@ mod tests { assert!(store.claim_secret("secret").await); assert!(!store.claim_secret("secret").await); } + + #[tokio::test] + async fn session_store_remove_reports_presence() { + let store = Nip46SessionStore::new(); + store.insert(build_session("remove", None)).await; + assert!(store.remove("remove").await); + assert!(!store.remove("remove").await); + } + + #[test] + fn session_expires_at_handles_zero_and_positive() { + assert!(session_expires_at(0).is_none()); + assert!(session_expires_at(10).is_some()); + } + + #[test] + fn session_is_expired_respects_future_and_none() { + let session = build_session("active", Some(Instant::now() + Duration::from_secs(1))); + assert!(!session.is_expired()); + let session = build_session("never", None); + assert!(!session.is_expired()); + } + + #[test] + fn session_is_expired_for_past_deadline() { + let session = build_session("expired", Some(Instant::now() - Duration::from_secs(1))); + assert!(session.is_expired()); + } + + #[tokio::test] + async fn session_store_set_user_pubkey_handles_missing_and_expired() { + let store = Nip46SessionStore::new(); + let keys = RadrootsNostrKeys::generate(); + assert!(!store.set_user_pubkey("missing", keys.public_key()).await); + + let session = build_session( + "expired-user", + Some(Instant::now() - Duration::from_secs(1)), + ); + store.insert(session).await; + assert!(!store.set_user_pubkey("expired-user", keys.public_key()).await); + } + + #[tokio::test] + async fn session_store_set_user_pubkey_sets_value_for_active_session() { + let store = Nip46SessionStore::new(); + let session = build_session( + "active-user", + Some(Instant::now() + Duration::from_secs(30)), + ); + let keys = RadrootsNostrKeys::generate(); + let pubkey = keys.public_key(); + store.insert(session).await; + assert!(store.set_user_pubkey("active-user", pubkey).await); + let found = store.get("active-user").await.expect("session"); + assert_eq!(found.user_pubkey, Some(pubkey)); + } + + #[tokio::test] + async fn session_store_require_auth_sets_flags_and_clears_pending() { + let store = Nip46SessionStore::new(); + let mut session = build_session( + "auth", + Some(Instant::now() + Duration::from_secs(30)), + ); + let keys = RadrootsNostrKeys::generate(); + session.pending_request = Some(PendingNostrRequest { + request_id: "req-1".to_string(), + client_pubkey: keys.public_key(), + request: NostrConnectRequest::Ping, + }); + store.insert(session).await; + + assert!(store.require_auth("auth", "https://auth".to_string()).await); + let found = store.get("auth").await.expect("session"); + assert!(found.auth_required); + assert!(!found.authorized); + assert_eq!(found.auth_url, Some("https://auth".to_string())); + assert!(found.pending_request.is_none()); + } + + #[tokio::test] + async fn session_store_require_auth_handles_missing_and_expired() { + let store = Nip46SessionStore::new(); + assert!(!store.require_auth("missing", "https://auth".to_string()).await); + + store + .insert(build_session( + "expired-auth", + Some(Instant::now() - Duration::from_secs(1)), + )) + .await; + assert!( + !store + .require_auth("expired-auth", "https://auth".to_string()) + .await + ); + } + + #[tokio::test] + async fn session_store_authorize_returns_pending() { + let store = Nip46SessionStore::new(); + let mut session = build_session( + "authorize", + Some(Instant::now() + Duration::from_secs(30)), + ); + let keys = RadrootsNostrKeys::generate(); + session.pending_request = Some(PendingNostrRequest { + request_id: "req-2".to_string(), + client_pubkey: keys.public_key(), + request: NostrConnectRequest::GetPublicKey, + }); + store.insert(session).await; + + let outcome = store.authorize("authorize").await.expect("outcome"); + assert!(outcome.pending.is_some()); + let found = store.get("authorize").await.expect("session"); + assert!(found.authorized); + } + + #[tokio::test] + async fn session_store_authorize_handles_missing_and_expired() { + let store = Nip46SessionStore::new(); + assert!(store.authorize("missing").await.is_none()); + + store + .insert(build_session( + "expired-authorize", + Some(Instant::now() - Duration::from_secs(1)), + )) + .await; + assert!(store.authorize("expired-authorize").await.is_none()); + } + + #[tokio::test] + async fn session_store_set_pending_request_handles_missing_and_expired() { + let store = Nip46SessionStore::new(); + let keys = RadrootsNostrKeys::generate(); + let pending = PendingNostrRequest { + request_id: "req-3".to_string(), + client_pubkey: keys.public_key(), + request: NostrConnectRequest::Ping, + }; + assert!(!store.set_pending_request("missing", pending.clone()).await); + + let session = build_session( + "expired-pending", + Some(Instant::now() - Duration::from_secs(1)), + ); + store.insert(session).await; + assert!(!store.set_pending_request("expired-pending", pending).await); + } + + #[tokio::test] + async fn session_store_set_pending_request_succeeds_for_active_session() { + let store = Nip46SessionStore::new(); + store + .insert(build_session( + "pending", + Some(Instant::now() + Duration::from_secs(30)), + )) + .await; + let keys = RadrootsNostrKeys::generate(); + let pending = PendingNostrRequest { + request_id: "req-active".to_string(), + client_pubkey: keys.public_key(), + request: NostrConnectRequest::Ping, + }; + assert!(store.set_pending_request("pending", pending).await); + let found = store.get("pending").await.expect("session"); + assert!(found.pending_request.is_some()); + } + + #[tokio::test] + async fn session_store_list_sorts_ids() { + let store = Nip46SessionStore::new(); + store + .insert(build_session( + "b", + Some(Instant::now() + Duration::from_secs(10)), + )) + .await; + store + .insert(build_session( + "a", + Some(Instant::now() + Duration::from_secs(10)), + )) + .await; + let listed = store.list().await; + assert_eq!(listed.len(), 2); + assert_eq!(listed[0].id, "a"); + assert_eq!(listed[1].id, "b"); + } + + #[test] + fn filter_perms_empty_allowed_returns_empty() { + let requested = vec!["nip04_encrypt".to_string()]; + let filtered = filter_perms(&requested, &[]); + assert!(filtered.is_empty()); + } + + #[test] + fn filter_perms_exact_match_and_rejects_unlisted() { + let requested = vec![ + "nip04_encrypt".to_string(), + "nip44_encrypt".to_string(), + "sign_event:1".to_string(), + ]; + let allowed = vec!["nip04_encrypt".to_string()]; + let filtered = filter_perms(&requested, &allowed); + assert_eq!(filtered, vec!["nip04_encrypt".to_string()]); + } + + #[test] + fn filter_perms_sign_event_global_does_not_allow_unrelated_perm() { + let requested = vec!["nip44_encrypt".to_string()]; + let allowed = vec!["sign_event".to_string()]; + let filtered = filter_perms(&requested, &allowed); + assert!(filtered.is_empty()); + } + + #[test] + fn sign_event_allowed_accepts_global_permission() { + let perms = vec!["sign_event".to_string()]; + assert!(sign_event_allowed(&perms, 4)); + } } diff --git a/src/core/state.rs b/src/core/state.rs @@ -43,3 +43,26 @@ impl Radrootsd { } } } + +#[cfg(test)] +mod tests { + use super::Radrootsd; + use crate::app::config::Nip46Config; + use radroots_nostr::prelude::{RadrootsNostrKeys, RadrootsNostrMetadata}; + + #[test] + fn new_sets_core_fields() { + let keys = RadrootsNostrKeys::generate(); + let metadata: RadrootsNostrMetadata = + serde_json::from_str(r#"{"name":"radrootsd-test"}"#).expect("metadata"); + let cfg = Nip46Config::default(); + let state = Radrootsd::new(keys.clone(), metadata.clone(), cfg.clone()); + + assert_eq!(state.pubkey, keys.public_key()); + assert_eq!(state.metadata, metadata); + assert_eq!(state.nip46_config.session_ttl_secs, cfg.session_ttl_secs); + assert_eq!(state.nip46_config.perms, cfg.perms); + assert_eq!(state.info["version"], env!("CARGO_PKG_VERSION")); + assert_eq!(state.info["build"], "unknown"); + } +} diff --git a/src/lib.rs b/src/lib.rs @@ -3,4 +3,18 @@ pub mod app; pub mod core; +#[cfg_attr(coverage_nightly, coverage(off))] pub mod transport; + +pub const fn crate_name() -> &'static str { + env!("CARGO_PKG_NAME") +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + #[test] + fn crate_name_matches_package() { + assert_eq!(super::crate_name(), "radrootsd"); + } +} diff --git a/src/main.rs b/src/main.rs @@ -5,9 +5,20 @@ use std::process::ExitCode; use anyhow::Result; +#[cfg(not(test))] +#[cfg_attr(coverage_nightly, coverage(off))] #[tokio::main] async fn main() -> ExitCode { - match run().await { + exit_code_from_run(run().await) +} + +#[cfg(test)] +fn main() -> ExitCode { + exit_code_from_run(Ok(())) +} + +fn exit_code_from_run(result: Result<()>) -> ExitCode { + match result { Ok(()) => ExitCode::SUCCESS, Err(err) => { tracing::error!(error = ?err, "Fatal error"); @@ -17,6 +28,82 @@ async fn main() -> ExitCode { } } +#[cfg(test)] +static RUN_HOOK: std::sync::OnceLock<std::sync::Mutex<Option<Result<(), String>>>> = + std::sync::OnceLock::new(); + +#[cfg(test)] +fn run_hook() -> &'static std::sync::Mutex<Option<Result<(), String>>> { + RUN_HOOK.get_or_init(|| std::sync::Mutex::new(None)) +} + +#[cfg(test)] +fn take_run_hook_result() -> Option<Result<(), String>> { + run_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .take() +} + +#[cfg(test)] +async fn run() -> Result<()> { + if let Some(result) = take_run_hook_result() { + return result.map_err(anyhow::Error::msg); + } + Err(anyhow::anyhow!("run hook not set")) +} + +#[cfg(not(test))] +#[cfg_attr(coverage_nightly, coverage(off))] async fn run() -> Result<()> { radrootsd::app::run().await } + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::{exit_code_from_run, main, run, run_hook}; + use std::process::ExitCode; + use std::sync::Mutex; + + static TEST_LOCK: Mutex<()> = Mutex::new(()); + + fn test_guard() -> std::sync::MutexGuard<'static, ()> { + let guard = TEST_LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + *run_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = None; + guard + } + + #[test] + fn exit_code_from_run_maps_success_and_error() { + assert_eq!(exit_code_from_run(Ok(())), ExitCode::SUCCESS); + assert_eq!( + exit_code_from_run(Err(anyhow::anyhow!("boom"))), + ExitCode::FAILURE + ); + } + + #[test] + fn main_returns_success_in_test_build() { + assert_eq!(main(), ExitCode::SUCCESS); + } + + #[tokio::test] + async fn run_returns_error_when_hook_is_missing() { + let _guard = test_guard(); + let err = run().await.expect_err("hook missing should error"); + let msg = format!("{err:#}"); + assert!(msg.contains("run hook not set")); + } + + #[tokio::test] + async fn run_uses_hook_result() { + let _guard = test_guard(); + *run_hook() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Ok(())); + assert!(run().await.is_ok()); + } +}