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:
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());
+ }
+}