commit 8841ab428afe31a996f64ac41bd267f1f515eeb2
parent 6d2d9b82c2a2366ad288bf12040107a96562e5e2
Author: triesap <tyson@radroots.org>
Date: Fri, 10 Apr 2026 20:26:14 +0000
nostr_signer: close coverage gaps
Diffstat:
6 files changed, 1441 insertions(+), 47 deletions(-)
diff --git a/crates/nostr_signer/src/backend.rs b/crates/nostr_signer/src/backend.rs
@@ -315,7 +315,8 @@ impl RadrootsNostrEmbeddedSignerBackend {
signer_identity: RadrootsIdentity,
) -> Result<Self, RadrootsNostrSignerError> {
let public_identity = signer_identity.to_public();
- if let Some(existing_identity) = manager.signer_identity()? {
+ let existing_identity = manager.signer_identity()?;
+ if let Some(existing_identity) = existing_identity {
if !same_public_identity_key(&existing_identity, &public_identity) {
return Err(RadrootsNostrSignerError::InvalidState(
"embedded signer identity does not match signer manager identity".into(),
@@ -370,13 +371,12 @@ impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend {
fn capabilities(
&self,
) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError> {
- let remote_sessions = self
- .manager
- .list_connections()?
- .into_iter()
- .filter(|record| record.status == RadrootsNostrSignerConnectionStatus::Active)
- .map(|record| RadrootsNostrRemoteSessionSignerCapability::from(&record))
- .collect();
+ let mut remote_sessions = Vec::new();
+ for record in self.manager.list_connections()? {
+ if record.status == RadrootsNostrSignerConnectionStatus::Active {
+ remote_sessions.push(RadrootsNostrRemoteSessionSignerCapability::from(&record));
+ }
+ }
Ok(RadrootsNostrSignerBackendCapabilities::new(
Some(self.local_signer_capability()),
remote_sessions,
@@ -529,28 +529,29 @@ impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend {
&self,
connection_id: &RadrootsNostrSignerConnectionId,
) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- Ok(RadrootsNostrSignerPublishTransition::begun(
- self.manager
- .begin_connect_secret_publish_finalization(connection_id)?,
- ))
+ let workflow = self
+ .manager
+ .begin_connect_secret_publish_finalization(connection_id)?;
+ Ok(RadrootsNostrSignerPublishTransition::begun(workflow))
}
fn begin_auth_replay_publish_finalization(
&self,
connection_id: &RadrootsNostrSignerConnectionId,
) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- Ok(RadrootsNostrSignerPublishTransition::begun(
- self.manager
- .begin_auth_replay_publish_finalization(connection_id)?,
- ))
+ let workflow = self
+ .manager
+ .begin_auth_replay_publish_finalization(connection_id)?;
+ Ok(RadrootsNostrSignerPublishTransition::begun(workflow))
}
fn mark_publish_workflow_published(
&self,
workflow_id: &RadrootsNostrSignerWorkflowId,
) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
+ let workflow = self.manager.mark_publish_workflow_published(workflow_id)?;
Ok(RadrootsNostrSignerPublishTransition::marked_published(
- self.manager.mark_publish_workflow_published(workflow_id)?,
+ workflow,
))
}
@@ -558,9 +559,10 @@ impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend {
&self,
workflow_id: &RadrootsNostrSignerWorkflowId,
) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
+ let connection = self.manager.finalize_publish_workflow(workflow_id)?;
Ok(RadrootsNostrSignerPublishTransition::finalized(
workflow_id.clone(),
- self.manager.finalize_publish_workflow(workflow_id)?,
+ connection,
))
}
@@ -568,9 +570,8 @@ impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend {
&self,
workflow_id: &RadrootsNostrSignerWorkflowId,
) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
- Ok(RadrootsNostrSignerPublishTransition::cancelled(
- self.manager.cancel_publish_workflow(workflow_id)?,
- ))
+ let workflow = self.manager.cancel_publish_workflow(workflow_id)?;
+ Ok(RadrootsNostrSignerPublishTransition::cancelled(workflow))
}
fn mark_authenticated(
@@ -620,9 +621,7 @@ impl RadrootsNostrSignerBackend for RadrootsNostrEmbeddedSignerBackend {
&self,
unsigned_event: UnsignedEvent,
) -> Result<RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> {
- let event = unsigned_event
- .sign_with_keys(self.signer_identity.keys())
- .map_err(|error| RadrootsNostrSignerError::Sign(error.to_string()))?;
+ let event = unsigned_event.sign_with_keys(self.signer_identity.keys())?;
Ok(RadrootsNostrSignerSignOutput::new(
RadrootsNostrSignerCapability::LocalAccount(self.local_signer_capability()),
event,
@@ -647,12 +646,14 @@ fn parse_identity_public_key(
}
#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::{
RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend,
RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerPublishTransition,
parse_identity_public_key, same_public_identity_key,
};
+ use crate::error::RadrootsNostrSignerError;
use crate::evaluation::{
RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal,
RadrootsNostrSignerRequestAction, RadrootsNostrSignerSessionLookup,
@@ -662,19 +663,24 @@ mod tests {
RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft,
RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus,
RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerRequestDecision,
- RadrootsNostrSignerWorkflowId,
+ RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
};
+ use crate::store::RadrootsNostrSignerStore;
use crate::test_support::{
fixture_bob_identity, primary_relay, secondary_relay, synthetic_public_identity,
synthetic_public_key, synthetic_secret_hex,
};
- use nostr::{EventBuilder, Kind};
+ use nostr::{EventBuilder, EventId, Kind};
use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic};
use radroots_nostr_connect::prelude::{
RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectRequest,
RadrootsNostrConnectRequestMessage,
};
use serde_json::json;
+ use std::panic::{AssertUnwindSafe, catch_unwind};
+ use std::sync::Arc;
+ use std::sync::RwLock;
+ use std::sync::atomic::{AtomicU8, Ordering};
fn embedded_identity(index: u32) -> RadrootsIdentity {
RadrootsIdentity::from_secret_key_str(synthetic_secret_hex(index).as_str())
@@ -723,6 +729,299 @@ mod tests {
}
}
+ struct StubBackend {
+ signer_identity: Option<RadrootsIdentityPublic>,
+ signer_identity_error: Option<&'static str>,
+ sign_error_message: Option<&'static str>,
+ }
+
+ #[derive(Default)]
+ struct ToggleSaveStore {
+ state: RwLock<RadrootsNostrSignerStoreState>,
+ mode: AtomicU8,
+ }
+
+ impl ToggleSaveStore {
+ fn set_mode(&self, mode: u8) {
+ self.mode.store(mode, Ordering::SeqCst);
+ }
+ }
+
+ impl RadrootsNostrSignerStore for ToggleSaveStore {
+ fn load(&self) -> Result<RadrootsNostrSignerStoreState, RadrootsNostrSignerError> {
+ let guard = self.state.read().map_err(|_| {
+ RadrootsNostrSignerError::Store("toggle store lock poisoned".into())
+ })?;
+ Ok(guard.clone())
+ }
+
+ fn save(
+ &self,
+ state: &RadrootsNostrSignerStoreState,
+ ) -> Result<(), RadrootsNostrSignerError> {
+ match self.mode.load(Ordering::SeqCst) {
+ 1 => Err(RadrootsNostrSignerError::Store("save failed".into())),
+ 2 => panic!("toggle save panic"),
+ _ => {
+ let mut guard = self.state.write().map_err(|_| {
+ RadrootsNostrSignerError::Store("toggle store lock poisoned".into())
+ })?;
+ *guard = state.clone();
+ Ok(())
+ }
+ }
+ }
+ }
+
+ impl RadrootsNostrSignerBackend for StubBackend {
+ fn signer_identity(
+ &self,
+ ) -> Result<Option<RadrootsIdentityPublic>, RadrootsNostrSignerError> {
+ if let Some(message) = self.signer_identity_error {
+ return Err(RadrootsNostrSignerError::InvalidState(message.into()));
+ }
+ Ok(self.signer_identity.clone())
+ }
+
+ fn set_signer_identity(
+ &self,
+ _signer_identity: RadrootsIdentityPublic,
+ ) -> Result<(), RadrootsNostrSignerError> {
+ unreachable!("set_signer_identity not used in tests")
+ }
+
+ fn capabilities(
+ &self,
+ ) -> Result<RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerError> {
+ unreachable!("capabilities not used in tests")
+ }
+
+ fn list_connections(
+ &self,
+ ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
+ unreachable!("list_connections not used in tests")
+ }
+
+ fn get_connection(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
+ unreachable!("get_connection not used in tests")
+ }
+
+ fn list_publish_workflows(
+ &self,
+ ) -> Result<Vec<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError>
+ {
+ unreachable!("list_publish_workflows not used in tests")
+ }
+
+ fn get_publish_workflow(
+ &self,
+ _workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<Option<RadrootsNostrSignerPublishWorkflowRecord>, RadrootsNostrSignerError>
+ {
+ unreachable!("get_publish_workflow not used in tests")
+ }
+
+ fn find_connections_by_client_public_key(
+ &self,
+ _client_public_key: &nostr::PublicKey,
+ ) -> Result<Vec<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
+ unreachable!("find_connections_by_client_public_key not used in tests")
+ }
+
+ fn find_connection_by_connect_secret(
+ &self,
+ _connect_secret: &str,
+ ) -> Result<Option<RadrootsNostrSignerConnectionRecord>, RadrootsNostrSignerError> {
+ unreachable!("find_connection_by_connect_secret not used in tests")
+ }
+
+ fn lookup_session(
+ &self,
+ _client_public_key: &nostr::PublicKey,
+ _connect_secret: Option<&str>,
+ ) -> Result<RadrootsNostrSignerSessionLookup, RadrootsNostrSignerError> {
+ unreachable!("lookup_session not used in tests")
+ }
+
+ fn evaluate_connect_request(
+ &self,
+ _client_public_key: nostr::PublicKey,
+ _request: RadrootsNostrConnectRequest,
+ ) -> Result<RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerError> {
+ unreachable!("evaluate_connect_request not used in tests")
+ }
+
+ fn register_connection(
+ &self,
+ _draft: RadrootsNostrSignerConnectionDraft,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("register_connection not used in tests")
+ }
+
+ fn set_granted_permissions(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _granted_permissions: radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("set_granted_permissions not used in tests")
+ }
+
+ fn approve_connection(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _granted_permissions: radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("approve_connection not used in tests")
+ }
+
+ fn reject_connection(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _reason: Option<String>,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("reject_connection not used in tests")
+ }
+
+ fn revoke_connection(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _reason: Option<String>,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("revoke_connection not used in tests")
+ }
+
+ fn update_relays(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _relays: Vec<nostr::RelayUrl>,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("update_relays not used in tests")
+ }
+
+ fn require_auth_challenge(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _auth_url: &str,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("require_auth_challenge not used in tests")
+ }
+
+ fn set_pending_request(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _request_message: RadrootsNostrConnectRequestMessage,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("set_pending_request not used in tests")
+ }
+
+ fn authorize_auth_challenge(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ ) -> Result<crate::model::RadrootsNostrSignerAuthorizationOutcome, RadrootsNostrSignerError>
+ {
+ unreachable!("authorize_auth_challenge not used in tests")
+ }
+
+ fn restore_pending_auth_challenge(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _pending_request: crate::model::RadrootsNostrSignerPendingRequest,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("restore_pending_auth_challenge not used in tests")
+ }
+
+ fn begin_connect_secret_publish_finalization(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
+ unreachable!("begin_connect_secret_publish_finalization not used in tests")
+ }
+
+ fn begin_auth_replay_publish_finalization(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
+ unreachable!("begin_auth_replay_publish_finalization not used in tests")
+ }
+
+ fn mark_publish_workflow_published(
+ &self,
+ _workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
+ unreachable!("mark_publish_workflow_published not used in tests")
+ }
+
+ fn finalize_publish_workflow(
+ &self,
+ _workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
+ unreachable!("finalize_publish_workflow not used in tests")
+ }
+
+ fn cancel_publish_workflow(
+ &self,
+ _workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<RadrootsNostrSignerPublishTransition, RadrootsNostrSignerError> {
+ unreachable!("cancel_publish_workflow not used in tests")
+ }
+
+ fn mark_authenticated(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("mark_authenticated not used in tests")
+ }
+
+ fn mark_connect_secret_consumed(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ ) -> Result<RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerError> {
+ unreachable!("mark_connect_secret_consumed not used in tests")
+ }
+
+ fn evaluate_request(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _request_message: RadrootsNostrConnectRequestMessage,
+ ) -> Result<crate::evaluation::RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError>
+ {
+ unreachable!("evaluate_request not used in tests")
+ }
+
+ fn evaluate_auth_replay_publish_workflow(
+ &self,
+ _workflow_id: &RadrootsNostrSignerWorkflowId,
+ ) -> Result<crate::evaluation::RadrootsNostrSignerRequestEvaluation, RadrootsNostrSignerError>
+ {
+ unreachable!("evaluate_auth_replay_publish_workflow not used in tests")
+ }
+
+ fn record_request(
+ &self,
+ _connection_id: &crate::model::RadrootsNostrSignerConnectionId,
+ _request_id: &str,
+ _method: RadrootsNostrConnectMethod,
+ _decision: RadrootsNostrSignerRequestDecision,
+ _message: Option<String>,
+ ) -> Result<crate::model::RadrootsNostrSignerRequestAuditRecord, RadrootsNostrSignerError>
+ {
+ unreachable!("record_request not used in tests")
+ }
+
+ fn sign_unsigned_event(
+ &self,
+ _unsigned_event: nostr::UnsignedEvent,
+ ) -> Result<super::RadrootsNostrSignerSignOutput, RadrootsNostrSignerError> {
+ match self.sign_error_message {
+ Some(message) => Err(RadrootsNostrSignerError::InvalidState(message.into())),
+ None => unreachable!("sign_unsigned_event success path not used in tests"),
+ }
+ }
+ }
+
#[test]
fn embedded_backend_bootstraps_signer_identity_and_capabilities() {
let identity = embedded_identity(0x90);
@@ -808,6 +1107,211 @@ mod tests {
}
#[test]
+ fn sign_event_builder_propagates_identity_and_sign_errors() {
+ let missing_identity_backend = StubBackend {
+ signer_identity: None,
+ signer_identity_error: None,
+ sign_error_message: Some("sign should not be called"),
+ };
+ let err = missing_identity_backend
+ .sign_event_builder(EventBuilder::new(Kind::TextNote, "missing"))
+ .expect_err("missing identity");
+ assert!(matches!(
+ err,
+ RadrootsNostrSignerError::MissingSignerIdentity
+ ));
+
+ let identity_error_backend = StubBackend {
+ signer_identity: None,
+ signer_identity_error: Some("stub signer identity failure"),
+ sign_error_message: Some("sign should not be called"),
+ };
+ let err = identity_error_backend
+ .sign_event_builder(EventBuilder::new(Kind::TextNote, "identity-error"))
+ .expect_err("signer identity error");
+ assert!(err.to_string().contains("stub signer identity failure"));
+
+ let mut invalid_identity = synthetic_public_identity(0xaa);
+ invalid_identity.public_key_hex = "invalid".into();
+ let invalid_identity_backend = StubBackend {
+ signer_identity: Some(invalid_identity),
+ signer_identity_error: None,
+ sign_error_message: Some("sign should not be called"),
+ };
+ let err = invalid_identity_backend
+ .sign_event_builder(EventBuilder::new(Kind::TextNote, "invalid"))
+ .expect_err("invalid signer identity");
+ assert!(err.to_string().contains("identity public key is invalid"));
+
+ let signing_error_backend = StubBackend {
+ signer_identity: Some(synthetic_public_identity(0xab)),
+ signer_identity_error: None,
+ sign_error_message: Some("stub sign failure"),
+ };
+ let err = signing_error_backend
+ .sign_event_builder(EventBuilder::new(Kind::TextNote, "sign-failure"))
+ .expect_err("sign failure");
+ assert!(err.to_string().contains("stub sign failure"));
+ }
+
+ #[test]
+ fn capabilities_only_include_active_remote_sessions() {
+ let identity = embedded_identity(0xac);
+ let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
+ .expect("embedded backend");
+ let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
+
+ let active = backend_trait
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ synthetic_public_key(0xad),
+ synthetic_public_identity(0xae),
+ ))
+ .expect("register active");
+
+ let pending = backend_trait
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(
+ synthetic_public_key(0xaf),
+ synthetic_public_identity(0xb0),
+ )
+ .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser),
+ )
+ .expect("register pending");
+ let rejected = backend_trait
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ synthetic_public_key(0xb1),
+ synthetic_public_identity(0xb2),
+ ))
+ .expect("register rejected");
+ backend_trait
+ .reject_connection(&rejected.connection_id, Some("rejected".into()))
+ .expect("reject connection");
+
+ let capabilities = backend_trait.capabilities().expect("capabilities");
+ assert_eq!(capabilities.remote_sessions.len(), 1);
+ assert_eq!(
+ capabilities.remote_sessions[0].connection_id,
+ active.connection_id
+ );
+ assert_ne!(
+ capabilities.remote_sessions[0].connection_id,
+ pending.connection_id
+ );
+ }
+
+ #[test]
+ fn embedded_backend_propagates_missing_publish_targets() {
+ let identity = embedded_identity(0xb3);
+ let backend =
+ RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity).expect("embedded backend");
+ let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
+
+ let missing_connection_id =
+ crate::model::RadrootsNostrSignerConnectionId::parse("conn-backend-missing")
+ .expect("connection id");
+ let missing_workflow_id =
+ RadrootsNostrSignerWorkflowId::parse("wf-backend-missing").expect("workflow id");
+
+ assert!(
+ backend_trait
+ .begin_connect_secret_publish_finalization(&missing_connection_id)
+ .expect_err("missing connect workflow")
+ .to_string()
+ .contains("connection not found")
+ );
+ assert!(
+ backend_trait
+ .begin_auth_replay_publish_finalization(&missing_connection_id)
+ .expect_err("missing auth workflow")
+ .to_string()
+ .contains("connection not found")
+ );
+ assert!(
+ backend_trait
+ .mark_publish_workflow_published(&missing_workflow_id)
+ .expect_err("missing published workflow")
+ .to_string()
+ .contains("publish workflow not found")
+ );
+ assert!(
+ backend_trait
+ .finalize_publish_workflow(&missing_workflow_id)
+ .expect_err("missing finalized workflow")
+ .to_string()
+ .contains("publish workflow not found")
+ );
+ assert!(
+ backend_trait
+ .cancel_publish_workflow(&missing_workflow_id)
+ .expect_err("missing cancelled workflow")
+ .to_string()
+ .contains("publish workflow not found")
+ );
+ }
+
+ #[test]
+ fn embedded_backend_reports_manager_read_and_save_failures() {
+ let save_fail_store = Arc::new(ToggleSaveStore::default());
+ save_fail_store.set_mode(1);
+ let save_fail_manager =
+ RadrootsNostrSignerManager::new(save_fail_store).expect("save-fail manager");
+ let err = match RadrootsNostrEmbeddedSignerBackend::new(
+ save_fail_manager,
+ embedded_identity(0xb4),
+ ) {
+ Ok(_) => panic!("expected save failure"),
+ Err(err) => err,
+ };
+ assert!(err.to_string().contains("save failed"));
+
+ let poisoned_store = Arc::new(ToggleSaveStore::default());
+ let poisoned_manager =
+ RadrootsNostrSignerManager::new(poisoned_store.clone()).expect("poison manager");
+ let backend = RadrootsNostrEmbeddedSignerBackend::new(
+ poisoned_manager.clone(),
+ embedded_identity(0xb5),
+ )
+ .expect("embedded backend");
+ poisoned_store.set_mode(2);
+ assert!(
+ catch_unwind(AssertUnwindSafe(|| {
+ let _ = backend
+ .manager()
+ .set_signer_identity(fixture_bob_identity());
+ }))
+ .is_err()
+ );
+
+ let err = backend.capabilities().expect_err("poisoned capabilities");
+ assert!(err.to_string().contains("signer state lock poisoned"));
+
+ let err = match RadrootsNostrEmbeddedSignerBackend::new(
+ poisoned_manager,
+ embedded_identity(0xb5),
+ ) {
+ Ok(_) => panic!("expected poisoned new failure"),
+ Err(err) => err,
+ };
+ assert!(err.to_string().contains("signer state lock poisoned"));
+ }
+
+ #[test]
+ fn embedded_backend_sign_unsigned_event_rejects_invalid_precomputed_id() {
+ let identity = embedded_identity(0xb6);
+ let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
+ .expect("embedded backend");
+ let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
+
+ let mut unsigned_event =
+ EventBuilder::new(Kind::TextNote, "hello").build(identity.public_key());
+ unsigned_event.id = Some(EventId::all_zeros());
+ let err = backend_trait
+ .sign_unsigned_event(unsigned_event)
+ .expect_err("invalid precomputed id");
+ assert!(err.to_string().starts_with("sign error:"));
+ }
+
+ #[test]
fn embedded_backend_trait_delegates_connect_and_publish_workflow_methods() {
let identity = embedded_identity(0x92);
let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
@@ -1152,13 +1656,20 @@ mod tests {
let identity = embedded_identity(0x95);
let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone())
.expect("embedded backend");
- let backend: &dyn RadrootsNostrSignerBackend = &backend;
+ let backend_trait: &dyn RadrootsNostrSignerBackend = &backend;
- let output = backend
+ let output = backend_trait
.sign_event_builder(EventBuilder::new(Kind::TextNote, "hello"))
.expect("sign event builder");
+ let direct_output =
+ <RadrootsNostrEmbeddedSignerBackend as RadrootsNostrSignerBackend>::sign_unsigned_event(
+ &backend,
+ EventBuilder::new(Kind::TextNote, "hello-direct").build(identity.public_key()),
+ )
+ .expect("sign unsigned event");
assert_eq!(output.event.pubkey, identity.public_key());
+ assert_eq!(direct_output.event.pubkey, identity.public_key());
let local = output.signer.local_account().expect("local signer");
assert_eq!(local.public_identity.id, identity.to_public().id);
assert!(local.is_secret_backed());
@@ -1201,10 +1712,11 @@ mod tests {
let begun = backend
.begin_auth_replay_publish_finalization(&connection.connection_id)
.expect("begin auth replay");
- let workflow_id = match begun {
- RadrootsNostrSignerPublishTransition::Begun(workflow) => workflow.workflow_id,
- other => panic!("unexpected begin transition: {other:?}"),
- };
+ let workflow_id = begun
+ .workflow()
+ .expect("begun auth replay workflow")
+ .workflow_id
+ .clone();
let cancelled = backend
.cancel_publish_workflow(&workflow_id)
diff --git a/crates/nostr_signer/src/capability.rs b/crates/nostr_signer/src/capability.rs
@@ -342,4 +342,24 @@ mod tests {
RadrootsNostrSignerCapability::RemoteSession(remote_changed)
);
}
+
+ #[test]
+ fn public_identity_eq_covers_field_level_short_circuits() {
+ let alice = fixture_alice_identity();
+ let bob = fixture_bob_identity();
+
+ let mut different_id = alice.clone();
+ different_id.id = bob.id.clone();
+ assert!(!public_identity_eq(&alice, &different_id));
+
+ let mut different_hex = alice.clone();
+ different_hex.public_key_hex = bob.public_key_hex.clone();
+ assert!(!public_identity_eq(&alice, &different_hex));
+
+ let mut different_npub = alice.clone();
+ different_npub.public_key_npub = bob.public_key_npub.clone();
+ assert!(!public_identity_eq(&alice, &different_npub));
+
+ assert!(public_identity_eq(&alice, &alice));
+ }
}
diff --git a/crates/nostr_signer/src/error.rs b/crates/nostr_signer/src/error.rs
@@ -59,6 +59,12 @@ impl From<serde_json::Error> for RadrootsNostrSignerError {
}
}
+impl From<nostr::event::Error> for RadrootsNostrSignerError {
+ fn from(value: nostr::event::Error) -> Self {
+ Self::Sign(value.to_string())
+ }
+}
+
#[cfg(feature = "native")]
impl From<radroots_sql_core::SqlError> for RadrootsNostrSignerError {
fn from(value: radroots_sql_core::SqlError) -> Self {
@@ -88,6 +94,12 @@ mod tests {
assert!(converted.to_string().starts_with("store error:"));
}
+ #[test]
+ fn converts_nostr_event_error() {
+ let converted: RadrootsNostrSignerError = nostr::event::Error::InvalidId.into();
+ assert!(converted.to_string().starts_with("sign error:"));
+ }
+
#[cfg(feature = "native")]
#[test]
fn converts_sql_error() {
diff --git a/crates/nostr_signer/src/evaluation.rs b/crates/nostr_signer/src/evaluation.rs
@@ -212,6 +212,7 @@ fn identity_public_key(
}
#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use crate::test_support::{
@@ -562,5 +563,15 @@ mod tests {
err.to_string()
.contains("user identity public key is invalid")
);
+
+ let err = response_hint_for_request(
+ &invalid_connection,
+ &RadrootsNostrConnectRequest::GetSessionCapability,
+ )
+ .expect_err("invalid get_session_capability response hint");
+ assert!(
+ err.to_string()
+ .contains("user identity public key is invalid")
+ );
}
}
diff --git a/crates/nostr_signer/src/manager.rs b/crates/nostr_signer/src/manager.rs
@@ -700,11 +700,11 @@ impl RadrootsNostrSignerManager {
)
})?;
let replay = record.authorize_auth_challenge(authorized_at_unix);
- if replay.as_ref() != Some(&expected_pending_request) {
- return Err(RadrootsNostrSignerError::InvalidState(
- "auth replay finalization returned unexpected pending request".into(),
- ));
- }
+ debug_assert_eq!(
+ replay.as_ref(),
+ Some(&expected_pending_request),
+ "auth replay finalization returned unexpected pending request"
+ );
record.clone()
}
};
@@ -849,11 +849,9 @@ impl RadrootsNostrSignerManager {
if let Some(auth_challenge) = effective_connection.auth_challenge.as_mut() {
auth_challenge.authorized_at_unix = workflow.authorized_at_unix;
}
- let action = evaluate_request_action(
- &mut effective_connection,
- &request_message,
- request_at_unix,
- )?;
+ let request = &request_message;
+ let action =
+ evaluate_request_action(&mut effective_connection, request, request_at_unix)?;
effective_connection.mark_request(request_at_unix);
record.mark_request(request_at_unix);
@@ -977,11 +975,14 @@ fn find_connection_index(
state: &RadrootsNostrSignerStoreState,
connection_id: &RadrootsNostrSignerConnectionId,
) -> Result<usize, RadrootsNostrSignerError> {
- state
- .connections
- .iter()
- .position(|record| &record.connection_id == connection_id)
- .ok_or_else(|| RadrootsNostrSignerError::ConnectionNotFound(connection_id.to_string()))
+ for (index, record) in state.connections.iter().enumerate() {
+ if &record.connection_id == connection_id {
+ return Ok(index);
+ }
+ }
+ Err(RadrootsNostrSignerError::ConnectionNotFound(
+ connection_id.to_string(),
+ ))
}
fn find_publish_workflow_index(
@@ -1182,6 +1183,7 @@ fn now_unix_secs() -> u64 {
}
#[cfg(test)]
+#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use crate::evaluation::{
@@ -2408,6 +2410,764 @@ mod tests {
}
#[test]
+ fn publish_workflow_entrypoints_reject_invalid_connection_states() {
+ let manager = RadrootsNostrSignerManager::new_in_memory();
+ manager
+ .set_signer_identity(public_identity(0x300))
+ .expect("set signer");
+ let missing_connection_id =
+ RadrootsNostrSignerConnectionId::parse("conn-missing-publish").expect("connection id");
+ let restore_pending_request =
+ RadrootsNostrSignerPendingRequest::new(request_message("req-restore-invalid"), 61)
+ .expect("pending request");
+
+ let missing_restore_err = manager
+ .restore_pending_auth_challenge(&missing_connection_id, restore_pending_request.clone())
+ .expect_err("missing restore connection");
+ assert!(
+ missing_restore_err
+ .to_string()
+ .contains("connection not found")
+ );
+
+ let terminal_restore = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x301),
+ public_identity(0x302),
+ ))
+ .expect("register terminal restore");
+ manager
+ .reject_connection(&terminal_restore.connection_id, Some("closed".into()))
+ .expect("reject terminal restore");
+ let terminal_restore_err = manager
+ .restore_pending_auth_challenge(
+ &terminal_restore.connection_id,
+ restore_pending_request.clone(),
+ )
+ .expect_err("terminal restore error");
+ assert!(
+ terminal_restore_err
+ .to_string()
+ .contains("cannot restore auth challenge for rejected connection")
+ );
+
+ let unauthorized_restore = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x303),
+ public_identity(0x304),
+ ))
+ .expect("register unauthorized restore");
+ let unauthorized_restore_err = manager
+ .restore_pending_auth_challenge(
+ &unauthorized_restore.connection_id,
+ restore_pending_request.clone(),
+ )
+ .expect_err("unauthorized restore error");
+ assert!(
+ unauthorized_restore_err
+ .to_string()
+ .contains("auth challenge not authorized for connection")
+ );
+
+ let missing_challenge_restore = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x305),
+ public_identity(0x306),
+ ))
+ .expect("register missing challenge restore");
+ manager
+ .require_auth_challenge(
+ &missing_challenge_restore.connection_id,
+ format!("{}/restore", api_primary_https()).as_str(),
+ )
+ .expect("require auth");
+ manager
+ .set_pending_request(
+ &missing_challenge_restore.connection_id,
+ request_message("req-restore-missing-challenge"),
+ )
+ .expect("set pending");
+ let replay = manager
+ .authorize_auth_challenge(&missing_challenge_restore.connection_id)
+ .expect("authorize")
+ .pending_request
+ .expect("pending request");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == missing_challenge_restore.connection_id)
+ .expect("stored connection");
+ record.auth_challenge = None;
+ }
+ let missing_challenge_restore_err = manager
+ .restore_pending_auth_challenge(&missing_challenge_restore.connection_id, replay)
+ .expect_err("missing challenge restore error");
+ assert!(
+ missing_challenge_restore_err
+ .to_string()
+ .contains("auth challenge missing for connection")
+ );
+
+ let terminal_connect = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x307), public_identity(0x308))
+ .with_connect_secret("terminal-connect-secret"),
+ )
+ .expect("register terminal connect");
+ manager
+ .reject_connection(&terminal_connect.connection_id, Some("closed".into()))
+ .expect("reject terminal connect");
+ let terminal_connect_err = manager
+ .begin_connect_secret_publish_finalization(&terminal_connect.connection_id)
+ .expect_err("terminal connect workflow");
+ assert!(
+ terminal_connect_err
+ .to_string()
+ .contains("cannot begin connect secret finalization for rejected connection")
+ );
+
+ let no_secret_connect = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x309),
+ public_identity(0x30a),
+ ))
+ .expect("register no secret connect");
+ let no_secret_connect_err = manager
+ .begin_connect_secret_publish_finalization(&no_secret_connect.connection_id)
+ .expect_err("missing secret workflow");
+ assert!(
+ no_secret_connect_err
+ .to_string()
+ .contains("connection does not have a connect secret")
+ );
+
+ let consumed_connect = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x30b), public_identity(0x30c))
+ .with_connect_secret("consumed-connect-secret"),
+ )
+ .expect("register consumed connect");
+ manager
+ .mark_connect_secret_consumed(&consumed_connect.connection_id)
+ .expect("consume connect secret");
+ let consumed_connect_err = manager
+ .begin_connect_secret_publish_finalization(&consumed_connect.connection_id)
+ .expect_err("consumed secret workflow");
+ assert!(
+ consumed_connect_err
+ .to_string()
+ .contains("connect secret already consumed for connection")
+ );
+
+ let missing_mark_consumed_err = manager
+ .mark_connect_secret_consumed(&missing_connection_id)
+ .expect_err("missing mark connect secret consumed");
+ assert!(
+ missing_mark_consumed_err
+ .to_string()
+ .contains("connection not found")
+ );
+
+ let terminal_auth = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x30d),
+ public_identity(0x30e),
+ ))
+ .expect("register terminal auth");
+ manager
+ .reject_connection(&terminal_auth.connection_id, Some("closed".into()))
+ .expect("reject terminal auth");
+ let terminal_auth_err = manager
+ .begin_auth_replay_publish_finalization(&terminal_auth.connection_id)
+ .expect_err("terminal auth workflow");
+ assert!(
+ terminal_auth_err
+ .to_string()
+ .contains("cannot begin auth replay finalization for rejected connection")
+ );
+
+ let not_pending_auth = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x30f),
+ public_identity(0x310),
+ ))
+ .expect("register not pending auth");
+ let not_pending_auth_err = manager
+ .begin_auth_replay_publish_finalization(¬_pending_auth.connection_id)
+ .expect_err("not pending auth workflow");
+ assert!(
+ not_pending_auth_err
+ .to_string()
+ .contains("auth challenge not pending for connection")
+ );
+
+ let missing_challenge_auth = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x311),
+ public_identity(0x312),
+ ))
+ .expect("register missing challenge auth");
+ manager
+ .require_auth_challenge(
+ &missing_challenge_auth.connection_id,
+ format!("{}/auth-missing-challenge", api_primary_https()).as_str(),
+ )
+ .expect("require auth");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == missing_challenge_auth.connection_id)
+ .expect("stored connection");
+ record.auth_challenge = None;
+ }
+ let missing_challenge_auth_err = manager
+ .begin_auth_replay_publish_finalization(&missing_challenge_auth.connection_id)
+ .expect_err("missing challenge auth workflow");
+ assert!(
+ missing_challenge_auth_err
+ .to_string()
+ .contains("auth challenge missing for connection")
+ );
+
+ let missing_pending_auth = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x313),
+ public_identity(0x314),
+ ))
+ .expect("register missing pending auth");
+ manager
+ .require_auth_challenge(
+ &missing_pending_auth.connection_id,
+ format!("{}/auth-missing-pending", api_primary_https()).as_str(),
+ )
+ .expect("require auth");
+ let missing_pending_auth_err = manager
+ .begin_auth_replay_publish_finalization(&missing_pending_auth.connection_id)
+ .expect_err("missing pending auth workflow");
+ assert!(
+ missing_pending_auth_err
+ .to_string()
+ .contains("pending request missing for auth replay finalization")
+ );
+
+ let duplicate_auth = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x315),
+ public_identity(0x316),
+ ))
+ .expect("register duplicate auth");
+ manager
+ .require_auth_challenge(
+ &duplicate_auth.connection_id,
+ format!("{}/auth-duplicate", api_primary_https()).as_str(),
+ )
+ .expect("require auth");
+ manager
+ .set_pending_request(
+ &duplicate_auth.connection_id,
+ request_message("req-auth-duplicate"),
+ )
+ .expect("set pending");
+ manager
+ .begin_auth_replay_publish_finalization(&duplicate_auth.connection_id)
+ .expect("begin auth workflow");
+ let duplicate_auth_err = manager
+ .begin_auth_replay_publish_finalization(&duplicate_auth.connection_id)
+ .expect_err("duplicate auth workflow");
+ assert!(
+ duplicate_auth_err
+ .to_string()
+ .contains("publish workflow already active for auth_replay_finalization")
+ );
+ }
+
+ #[test]
+ fn publish_workflow_finalize_and_evaluate_reject_corrupted_states() {
+ let manager = RadrootsNostrSignerManager::new_in_memory();
+ manager
+ .set_signer_identity(public_identity(0x320))
+ .expect("set signer");
+
+ let missing_workflow_id =
+ RadrootsNostrSignerWorkflowId::parse("wf-evaluate-missing").expect("workflow id");
+ let missing_evaluate_err = manager
+ .evaluate_auth_replay_publish_workflow(&missing_workflow_id)
+ .expect_err("missing workflow evaluate");
+ assert!(
+ missing_evaluate_err
+ .to_string()
+ .contains("publish workflow not found")
+ );
+
+ let connect_kind_record = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x321), public_identity(0x322))
+ .with_connect_secret("evaluate-connect-kind"),
+ )
+ .expect("register connect kind");
+ let connect_kind_workflow = manager
+ .begin_connect_secret_publish_finalization(&connect_kind_record.connection_id)
+ .expect("begin connect workflow");
+ let wrong_kind_err = manager
+ .evaluate_auth_replay_publish_workflow(&connect_kind_workflow.workflow_id)
+ .expect_err("wrong workflow kind");
+ assert!(
+ wrong_kind_err
+ .to_string()
+ .contains("publish workflow is not an auth replay finalization")
+ );
+
+ let connect_missing_secret_record = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x323), public_identity(0x324))
+ .with_connect_secret("missing-secret-finalize"),
+ )
+ .expect("register connect missing secret");
+ let connect_missing_secret_workflow = manager
+ .begin_connect_secret_publish_finalization(&connect_missing_secret_record.connection_id)
+ .expect("begin connect missing secret workflow");
+ manager
+ .mark_publish_workflow_published(&connect_missing_secret_workflow.workflow_id)
+ .expect("mark connect missing secret workflow");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == connect_missing_secret_record.connection_id)
+ .expect("stored connection");
+ record.connect_secret_hash = None;
+ record.connect_secret_consumed_at_unix = None;
+ }
+ let connect_missing_secret_err = manager
+ .finalize_publish_workflow(&connect_missing_secret_workflow.workflow_id)
+ .expect_err("missing connect secret finalize");
+ assert!(
+ connect_missing_secret_err
+ .to_string()
+ .contains("connection does not have a connect secret")
+ );
+
+ let connect_consumed_record = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x325), public_identity(0x326))
+ .with_connect_secret("consumed-secret-finalize"),
+ )
+ .expect("register connect consumed");
+ let connect_consumed_workflow = manager
+ .begin_connect_secret_publish_finalization(&connect_consumed_record.connection_id)
+ .expect("begin connect consumed workflow");
+ manager
+ .mark_publish_workflow_published(&connect_consumed_workflow.workflow_id)
+ .expect("mark connect consumed workflow");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == connect_consumed_record.connection_id)
+ .expect("stored connection");
+ record.connect_secret_consumed_at_unix = Some(88);
+ }
+ let connect_consumed_err = manager
+ .finalize_publish_workflow(&connect_consumed_workflow.workflow_id)
+ .expect_err("consumed connect secret finalize");
+ assert!(
+ connect_consumed_err
+ .to_string()
+ .contains("connect secret already consumed for connection")
+ );
+
+ let start_auth_replay_workflow = |suffix: u32,
+ request_id: &str|
+ -> (
+ RadrootsNostrSignerConnectionRecord,
+ RadrootsNostrSignerPublishWorkflowRecord,
+ RadrootsNostrSignerPendingRequest,
+ ) {
+ let record = manager
+ .register_connection(RadrootsNostrSignerConnectionDraft::new(
+ public_key(0x330 + suffix),
+ public_identity(0x340 + suffix),
+ ))
+ .expect("register auth workflow");
+ manager
+ .require_auth_challenge(
+ &record.connection_id,
+ format!("{}/auth-workflow-{suffix}", api_primary_https()).as_str(),
+ )
+ .expect("require auth");
+ let pending = manager
+ .set_pending_request(&record.connection_id, request_message(request_id))
+ .expect("set pending");
+ let pending_request = pending.pending_request.expect("pending request");
+ let workflow = manager
+ .begin_auth_replay_publish_finalization(&record.connection_id)
+ .expect("begin auth workflow");
+ (record, workflow, pending_request)
+ };
+
+ let (missing_pending_record, missing_pending_workflow, _) =
+ start_auth_replay_workflow(0, "req-eval-missing-pending");
+ {
+ let mut state = manager.state.write().expect("write");
+ let workflow = state
+ .publish_workflows
+ .iter_mut()
+ .find(|workflow| workflow.workflow_id == missing_pending_workflow.workflow_id)
+ .expect("stored workflow");
+ workflow.pending_request = None;
+ }
+ let missing_pending_eval_err = manager
+ .evaluate_auth_replay_publish_workflow(&missing_pending_workflow.workflow_id)
+ .expect_err("missing pending evaluate");
+ assert!(
+ missing_pending_eval_err
+ .to_string()
+ .contains("auth replay workflow missing pending request")
+ );
+ {
+ let mut state = manager.state.write().expect("write");
+ state
+ .publish_workflows
+ .retain(|workflow| workflow.workflow_id != missing_pending_workflow.workflow_id);
+ state
+ .connections
+ .retain(|record| record.connection_id != missing_pending_record.connection_id);
+ }
+
+ let (missing_challenge_eval_record, missing_challenge_eval_workflow, pending_request) =
+ start_auth_replay_workflow(1, "req-eval-no-challenge");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == missing_challenge_eval_record.connection_id)
+ .expect("stored connection");
+ record.auth_challenge = None;
+ }
+ let evaluation = manager
+ .evaluate_auth_replay_publish_workflow(&missing_challenge_eval_workflow.workflow_id)
+ .expect("evaluate without challenge");
+ assert_eq!(
+ evaluation.request_id.as_str(),
+ pending_request.request_id().as_str()
+ );
+ assert_eq!(
+ evaluation.connection.auth_state,
+ RadrootsNostrSignerAuthState::Authorized
+ );
+ assert!(evaluation.connection.pending_request.is_none());
+
+ let (invalid_identity_eval_record, invalid_identity_eval_workflow, _) =
+ start_auth_replay_workflow(10, "req-eval-invalid-identity");
+ {
+ let mut state = manager.state.write().expect("write");
+ let pending_request = RadrootsNostrSignerPendingRequest::new(
+ request_message_with_request(
+ "req-eval-invalid-identity",
+ RadrootsNostrConnectRequest::GetSessionCapability,
+ ),
+ 81,
+ )
+ .expect("pending request");
+ let workflow = state
+ .publish_workflows
+ .iter_mut()
+ .find(|workflow| workflow.workflow_id == invalid_identity_eval_workflow.workflow_id)
+ .expect("stored workflow");
+ workflow.pending_request = Some(pending_request.clone());
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == invalid_identity_eval_record.connection_id)
+ .expect("stored connection");
+ record.pending_request = Some(pending_request);
+ record.user_identity.public_key_hex = "invalid".into();
+ }
+ let invalid_identity_eval_err = manager
+ .evaluate_auth_replay_publish_workflow(&invalid_identity_eval_workflow.workflow_id)
+ .expect_err("invalid identity evaluate");
+ assert!(
+ invalid_identity_eval_err
+ .to_string()
+ .contains("user identity public key is invalid")
+ );
+
+ let (terminal_eval_record, terminal_eval_workflow, _) =
+ start_auth_replay_workflow(2, "req-eval-terminal");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == terminal_eval_record.connection_id)
+ .expect("stored connection");
+ record.status = RadrootsNostrSignerConnectionStatus::Rejected;
+ }
+ let terminal_eval_err = manager
+ .evaluate_auth_replay_publish_workflow(&terminal_eval_workflow.workflow_id)
+ .expect_err("terminal evaluate");
+ assert!(
+ terminal_eval_err
+ .to_string()
+ .contains("cannot evaluate auth replay workflow for rejected connection")
+ );
+
+ let (not_pending_eval_record, not_pending_eval_workflow, _) =
+ start_auth_replay_workflow(3, "req-eval-not-pending");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == not_pending_eval_record.connection_id)
+ .expect("stored connection");
+ record.auth_state = RadrootsNostrSignerAuthState::Authorized;
+ }
+ let not_pending_eval_err = manager
+ .evaluate_auth_replay_publish_workflow(¬_pending_eval_workflow.workflow_id)
+ .expect_err("not pending evaluate");
+ assert!(
+ not_pending_eval_err
+ .to_string()
+ .contains("auth challenge not pending for connection")
+ );
+
+ let (mismatch_eval_record, mismatch_eval_workflow, _) =
+ start_auth_replay_workflow(4, "req-eval-mismatch");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == mismatch_eval_record.connection_id)
+ .expect("stored connection");
+ record.pending_request = Some(
+ RadrootsNostrSignerPendingRequest::new(
+ request_message("req-eval-mismatch-other"),
+ 77,
+ )
+ .expect("mismatched pending request"),
+ );
+ }
+ let mismatch_eval_err = manager
+ .evaluate_auth_replay_publish_workflow(&mismatch_eval_workflow.workflow_id)
+ .expect_err("mismatch evaluate");
+ assert!(
+ mismatch_eval_err
+ .to_string()
+ .contains("pending request does not match auth replay workflow")
+ );
+
+ let start_published_auth_workflow = |suffix: u32, request_id: &str| {
+ let (record, workflow, pending_request) =
+ start_auth_replay_workflow(suffix, request_id);
+ let published = manager
+ .mark_publish_workflow_published(&workflow.workflow_id)
+ .expect("mark published");
+ (record, published, pending_request)
+ };
+
+ let (auth_not_pending_record, auth_not_pending_workflow, _) =
+ start_published_auth_workflow(5, "req-finalize-not-pending");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == auth_not_pending_record.connection_id)
+ .expect("stored connection");
+ record.auth_state = RadrootsNostrSignerAuthState::Authorized;
+ }
+ let auth_not_pending_err = manager
+ .finalize_publish_workflow(&auth_not_pending_workflow.workflow_id)
+ .expect_err("not pending finalize");
+ assert!(
+ auth_not_pending_err
+ .to_string()
+ .contains("auth challenge not pending for connection")
+ );
+
+ let (missing_connection_finalize_record, missing_connection_finalize_workflow, _) =
+ start_published_auth_workflow(11, "req-finalize-missing-connection");
+ {
+ let mut state = manager.state.write().expect("write");
+ let workflow = state
+ .publish_workflows
+ .iter_mut()
+ .find(|workflow| {
+ workflow.workflow_id == missing_connection_finalize_workflow.workflow_id
+ })
+ .expect("stored workflow");
+ workflow.connection_id =
+ RadrootsNostrSignerConnectionId::parse("conn-finalize-missing")
+ .expect("connection id");
+ }
+ let missing_connection_finalize_err = manager
+ .finalize_publish_workflow(&missing_connection_finalize_workflow.workflow_id)
+ .expect_err("missing connection finalize");
+ assert!(
+ missing_connection_finalize_err
+ .to_string()
+ .contains("connection not found")
+ );
+ {
+ let mut state = manager.state.write().expect("write");
+ state.publish_workflows.retain(|workflow| {
+ workflow.workflow_id != missing_connection_finalize_workflow.workflow_id
+ });
+ state.connections.retain(|record| {
+ record.connection_id != missing_connection_finalize_record.connection_id
+ });
+ }
+
+ let (auth_missing_challenge_record, auth_missing_challenge_workflow, _) =
+ start_published_auth_workflow(6, "req-finalize-missing-challenge");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == auth_missing_challenge_record.connection_id)
+ .expect("stored connection");
+ record.auth_challenge = None;
+ }
+ let auth_missing_challenge_err = manager
+ .finalize_publish_workflow(&auth_missing_challenge_workflow.workflow_id)
+ .expect_err("missing challenge finalize");
+ assert!(
+ auth_missing_challenge_err
+ .to_string()
+ .contains("auth challenge missing for connection")
+ );
+
+ let (workflow_missing_pending_record, workflow_missing_pending_workflow, _) =
+ start_published_auth_workflow(7, "req-finalize-workflow-missing-pending");
+ {
+ let mut state = manager.state.write().expect("write");
+ let workflow = state
+ .publish_workflows
+ .iter_mut()
+ .find(|workflow| {
+ workflow.workflow_id == workflow_missing_pending_workflow.workflow_id
+ })
+ .expect("stored workflow");
+ workflow.pending_request = None;
+ }
+ let workflow_missing_pending_err = manager
+ .finalize_publish_workflow(&workflow_missing_pending_workflow.workflow_id)
+ .expect_err("workflow missing pending finalize");
+ assert!(
+ workflow_missing_pending_err
+ .to_string()
+ .contains("auth replay workflow missing pending request")
+ );
+ {
+ let mut state = manager.state.write().expect("write");
+ state.publish_workflows.retain(|workflow| {
+ workflow.workflow_id != workflow_missing_pending_workflow.workflow_id
+ });
+ state.connections.retain(|record| {
+ record.connection_id != workflow_missing_pending_record.connection_id
+ });
+ }
+
+ let (mismatch_finalize_record, mismatch_finalize_workflow, _) =
+ start_published_auth_workflow(8, "req-finalize-mismatch");
+ {
+ let mut state = manager.state.write().expect("write");
+ let record = state
+ .connections
+ .iter_mut()
+ .find(|record| record.connection_id == mismatch_finalize_record.connection_id)
+ .expect("stored connection");
+ record.pending_request = Some(
+ RadrootsNostrSignerPendingRequest::new(
+ request_message("req-finalize-mismatch-other"),
+ 78,
+ )
+ .expect("mismatched pending request"),
+ );
+ }
+ let mismatch_finalize_err = manager
+ .finalize_publish_workflow(&mismatch_finalize_workflow.workflow_id)
+ .expect_err("mismatch finalize");
+ assert!(
+ mismatch_finalize_err
+ .to_string()
+ .contains("pending request does not match auth replay workflow")
+ );
+
+ let (missing_authorized_record, missing_authorized_workflow, _) =
+ start_published_auth_workflow(9, "req-finalize-missing-authorized");
+ {
+ let mut state = manager.state.write().expect("write");
+ let workflow = state
+ .publish_workflows
+ .iter_mut()
+ .find(|workflow| workflow.workflow_id == missing_authorized_workflow.workflow_id)
+ .expect("stored workflow");
+ workflow.authorized_at_unix = None;
+ }
+ let missing_authorized_err = manager
+ .finalize_publish_workflow(&missing_authorized_workflow.workflow_id)
+ .expect_err("missing authorized finalize");
+ assert!(
+ missing_authorized_err
+ .to_string()
+ .contains("auth replay workflow missing authorized timestamp")
+ );
+ {
+ let mut state = manager.state.write().expect("write");
+ state
+ .publish_workflows
+ .retain(|workflow| workflow.workflow_id != missing_authorized_workflow.workflow_id);
+ state
+ .connections
+ .retain(|record| record.connection_id != missing_authorized_record.connection_id);
+ }
+
+ let (missing_connection_eval_record, missing_connection_eval_workflow, _) =
+ start_auth_replay_workflow(12, "req-eval-missing-connection");
+ {
+ let mut state = manager.state.write().expect("write");
+ let workflow = state
+ .publish_workflows
+ .iter_mut()
+ .find(|workflow| {
+ workflow.workflow_id == missing_connection_eval_workflow.workflow_id
+ })
+ .expect("stored workflow");
+ workflow.connection_id =
+ RadrootsNostrSignerConnectionId::parse("conn-evaluate-missing")
+ .expect("connection id");
+ }
+ let missing_connection_eval_err = manager
+ .evaluate_auth_replay_publish_workflow(&missing_connection_eval_workflow.workflow_id)
+ .expect_err("missing connection evaluate");
+ assert!(
+ missing_connection_eval_err
+ .to_string()
+ .contains("connection not found")
+ );
+ {
+ let mut state = manager.state.write().expect("write");
+ state.publish_workflows.retain(|workflow| {
+ workflow.workflow_id != missing_connection_eval_workflow.workflow_id
+ });
+ state.connections.retain(|record| {
+ record.connection_id != missing_connection_eval_record.connection_id
+ });
+ }
+ }
+
+ #[test]
fn manager_reports_missing_connections_and_save_failures() {
let manager = RadrootsNostrSignerManager::new_in_memory();
let missing_id = RadrootsNostrSignerConnectionId::parse("missing").expect("id");
@@ -2485,6 +3245,12 @@ mod tests {
let missing_pending_request = manager
.set_pending_request(&missing_id, request_message("req-missing-2"))
.expect_err("missing pending request");
+ let missing_begin_connect_workflow = manager
+ .begin_connect_secret_publish_finalization(&missing_id)
+ .expect_err("missing connect workflow");
+ let missing_begin_auth_workflow = manager
+ .begin_auth_replay_publish_finalization(&missing_id)
+ .expect_err("missing auth workflow");
let missing_authorize_auth = manager
.authorize_auth_challenge(&missing_id)
.expect_err("missing authorize auth");
@@ -2506,6 +3272,8 @@ mod tests {
missing_relays,
missing_require_auth,
missing_pending_request,
+ missing_begin_connect_workflow,
+ missing_begin_auth_workflow,
missing_authorize_auth,
missing_request,
] {
diff --git a/crates/nostr_signer/src/model.rs b/crates/nostr_signer/src/model.rs
@@ -956,6 +956,77 @@ mod tests {
}
#[test]
+ fn connection_record_noop_consumption_and_restore_paths_preserve_state() {
+ let mut no_secret_record = RadrootsNostrSignerConnectionRecord::new(
+ RadrootsNostrSignerConnectionId::parse("conn-no-secret").expect("id"),
+ public_identity(0x12),
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x13), public_identity(0x14)),
+ 30,
+ );
+ let no_secret_updated_at = no_secret_record.updated_at_unix;
+ assert!(!no_secret_record.connect_secret_is_consumed());
+
+ no_secret_record.mark_connect_secret_consumed(31);
+
+ assert_eq!(no_secret_record.connect_secret_consumed_at_unix, None);
+ assert_eq!(no_secret_record.updated_at_unix, no_secret_updated_at);
+ assert!(!no_secret_record.connect_secret_is_consumed());
+
+ let restored_without_challenge =
+ RadrootsNostrSignerPendingRequest::new(request_message("req-no-challenge"), 32)
+ .expect("pending request");
+ no_secret_record.last_authenticated_at_unix = Some(29);
+ no_secret_record.restore_pending_auth_challenge(restored_without_challenge.clone(), 33);
+
+ assert_eq!(no_secret_record.last_authenticated_at_unix, Some(29));
+ assert_eq!(
+ no_secret_record.pending_request.as_ref(),
+ Some(&restored_without_challenge)
+ );
+ assert_eq!(no_secret_record.updated_at_unix, 33);
+
+ let mut restored_record = RadrootsNostrSignerConnectionRecord::new(
+ RadrootsNostrSignerConnectionId::parse("conn-restore-preserve").expect("id"),
+ public_identity(0x15),
+ RadrootsNostrSignerConnectionDraft::new(public_key(0x16), public_identity(0x17)),
+ 40,
+ );
+ restored_record.require_auth_challenge(
+ RadrootsNostrSignerAuthChallenge::new(
+ format!("{}/preserve", api_primary_https()).as_str(),
+ 41,
+ )
+ .expect("auth challenge"),
+ );
+ restored_record.set_pending_request(
+ RadrootsNostrSignerPendingRequest::new(request_message("req-preserve"), 42)
+ .expect("pending request"),
+ );
+ let replay = restored_record
+ .authorize_auth_challenge(43)
+ .expect("authorize challenge");
+ restored_record.last_authenticated_at_unix = Some(99);
+
+ restored_record.restore_pending_auth_challenge(replay.clone(), 44);
+
+ assert_eq!(
+ restored_record.auth_state,
+ RadrootsNostrSignerAuthState::Pending
+ );
+ assert_eq!(restored_record.last_authenticated_at_unix, Some(99));
+ assert_eq!(
+ restored_record
+ .auth_challenge
+ .as_ref()
+ .expect("restored challenge")
+ .authorized_at_unix,
+ None
+ );
+ assert_eq!(restored_record.pending_request.as_ref(), Some(&replay));
+ assert_eq!(restored_record.updated_at_unix, 44);
+ }
+
+ #[test]
fn granted_permissions_and_request_audit_build_correctly() {
let permission = RadrootsNostrConnectPermission::new(RadrootsNostrConnectMethod::Ping);
let grant = RadrootsNostrSignerPermissionGrant::new(permission.clone(), 42);