lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 8841ab428afe31a996f64ac41bd267f1f515eeb2
parent 6d2d9b82c2a2366ad288bf12040107a96562e5e2
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 20:26:14 +0000

nostr_signer: close coverage gaps

Diffstat:
Mcrates/nostr_signer/src/backend.rs | 576++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/nostr_signer/src/capability.rs | 20++++++++++++++++++++
Mcrates/nostr_signer/src/error.rs | 12++++++++++++
Mcrates/nostr_signer/src/evaluation.rs | 11+++++++++++
Mcrates/nostr_signer/src/manager.rs | 798+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/nostr_signer/src/model.rs | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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(&not_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(&not_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);