lib

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

commit 6d2d9b82c2a2366ad288bf12040107a96562e5e2
parent 72a4c5bca0fccd89c3187a723b46eed790846b85
Author: triesap <tyson@radroots.org>
Date:   Fri, 10 Apr 2026 19:27:04 +0000

nostr_signer: cover backend helper gaps

Diffstat:
Mcrates/nostr_signer/src/backend.rs | 499++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/nostr_signer/src/capability.rs | 94++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/nostr_signer/src/error.rs | 16++++++++++++++++
3 files changed, 581 insertions(+), 28 deletions(-)

diff --git a/crates/nostr_signer/src/backend.rs b/crates/nostr_signer/src/backend.rs @@ -650,27 +650,79 @@ fn parse_identity_public_key( mod tests { use super::{ RadrootsNostrEmbeddedSignerBackend, RadrootsNostrSignerBackend, - RadrootsNostrSignerPublishTransition, + RadrootsNostrSignerBackendCapabilities, RadrootsNostrSignerPublishTransition, + parse_identity_public_key, same_public_identity_key, + }; + use crate::evaluation::{ + RadrootsNostrSignerConnectEvaluation, RadrootsNostrSignerConnectProposal, + RadrootsNostrSignerRequestAction, RadrootsNostrSignerSessionLookup, }; - use crate::evaluation::RadrootsNostrSignerConnectEvaluation; use crate::manager::RadrootsNostrSignerManager; - use crate::model::{RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerRequestDecision}; + use crate::model::{ + RadrootsNostrSignerApprovalRequirement, RadrootsNostrSignerConnectionDraft, + RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerConnectionStatus, + RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerRequestDecision, + RadrootsNostrSignerWorkflowId, + }; use crate::test_support::{ - fixture_bob_identity, primary_relay, synthetic_public_identity, synthetic_public_key, - synthetic_secret_hex, + fixture_bob_identity, primary_relay, secondary_relay, synthetic_public_identity, + synthetic_public_key, synthetic_secret_hex, }; use nostr::{EventBuilder, Kind}; - use radroots_identity::RadrootsIdentity; + use radroots_identity::{RadrootsIdentity, RadrootsIdentityPublic}; use radroots_nostr_connect::prelude::{ RadrootsNostrConnectMethod, RadrootsNostrConnectPermission, RadrootsNostrConnectRequest, RadrootsNostrConnectRequestMessage, }; + use serde_json::json; fn embedded_identity(index: u32) -> RadrootsIdentity { RadrootsIdentity::from_secret_key_str(synthetic_secret_hex(index).as_str()) .expect("identity") } + fn expect_registration_required( + evaluation: RadrootsNostrSignerConnectEvaluation, + ) -> RadrootsNostrSignerConnectProposal { + match evaluation { + RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => proposal, + other => panic!("unexpected connect evaluation: {other:?}"), + } + } + + fn expect_lookup_connection( + lookup: RadrootsNostrSignerSessionLookup, + ) -> RadrootsNostrSignerConnectionRecord { + match lookup { + RadrootsNostrSignerSessionLookup::Connection(found) => found, + other => panic!("unexpected session lookup: {other:?}"), + } + } + + fn expect_begun_workflow_id( + transition: RadrootsNostrSignerPublishTransition, + ) -> RadrootsNostrSignerWorkflowId { + match transition { + RadrootsNostrSignerPublishTransition::Begun(workflow) => workflow.workflow_id, + other => panic!("unexpected begin transition: {other:?}"), + } + } + + fn expect_finalized_transition( + transition: RadrootsNostrSignerPublishTransition, + ) -> ( + RadrootsNostrSignerWorkflowId, + RadrootsNostrSignerConnectionRecord, + ) { + match transition { + RadrootsNostrSignerPublishTransition::Finalized { + workflow_id, + connection, + } => (workflow_id, connection), + other => panic!("unexpected finalize transition: {other:?}"), + } + } + #[test] fn embedded_backend_bootstraps_signer_identity_and_capabilities() { let identity = embedded_identity(0x90); @@ -689,6 +741,16 @@ mod tests { assert!(local.is_secret_backed()); assert!(capabilities.remote_sessions.is_empty()); assert_eq!(capabilities.all_signers().len(), 1); + let manager_identity = backend + .manager() + .signer_identity() + .expect("manager signer identity") + .expect("stored signer identity"); + assert!(same_public_identity_key( + &manager_identity, + &identity.to_public() + )); + assert_eq!(backend.local_identity().public_key(), identity.public_key()); } #[test] @@ -709,6 +771,43 @@ mod tests { } #[test] + fn embedded_backend_accepts_matching_manager_identity_and_setter_delegate() { + let identity = embedded_identity(0x97); + let manager = RadrootsNostrSignerManager::new_in_memory(); + let public_identity = identity.to_public(); + manager + .set_signer_identity(public_identity.clone()) + .expect("prime manager identity"); + + let backend = RadrootsNostrEmbeddedSignerBackend::new(manager, identity.clone()) + .expect("matching embedded backend"); + let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; + + assert_eq!(backend.local_identity().public_key(), identity.public_key()); + assert!(same_public_identity_key( + backend_trait + .signer_identity() + .expect("signer identity") + .as_ref() + .expect("present"), + &public_identity + )); + + backend_trait + .set_signer_identity(public_identity.clone()) + .expect("delegate set signer identity"); + let manager_identity = backend + .manager() + .signer_identity() + .expect("manager signer identity") + .expect("stored signer identity"); + assert!(same_public_identity_key( + &manager_identity, + &public_identity + )); + } + + #[test] fn embedded_backend_trait_delegates_connect_and_publish_workflow_methods() { let identity = embedded_identity(0x92); let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) @@ -728,10 +827,7 @@ mod tests { }, ) .expect("connect evaluation"); - let proposal = match evaluation { - RadrootsNostrSignerConnectEvaluation::RegistrationRequired(proposal) => proposal, - other => panic!("unexpected connect evaluation: {other:?}"), - }; + let proposal = expect_registration_required(evaluation); let connection = backend .register_connection( proposal @@ -746,13 +842,11 @@ mod tests { let begun = backend .begin_connect_secret_publish_finalization(&connection.connection_id) .expect("begin workflow"); - let workflow_id = match begun { - RadrootsNostrSignerPublishTransition::Begun(workflow) => { - assert_eq!(workflow.connection_id, connection.connection_id); - workflow.workflow_id - } - other => panic!("unexpected begin transition: {other:?}"), - }; + let workflow_id = expect_begun_workflow_id(begun.clone()); + assert_eq!( + begun.workflow().expect("begun workflow").connection_id, + connection.connection_id + ); let published = backend .mark_publish_workflow_published(&workflow_id) @@ -765,16 +859,9 @@ mod tests { let finalized = backend .finalize_publish_workflow(&workflow_id) .expect("finalize workflow"); - match finalized { - RadrootsNostrSignerPublishTransition::Finalized { - workflow_id: finalized_workflow_id, - connection, - } => { - assert_eq!(finalized_workflow_id, workflow_id); - assert!(connection.connect_secret_is_consumed()); - } - other => panic!("unexpected finalize transition: {other:?}"), - } + let (finalized_workflow_id, finalized_connection) = expect_finalized_transition(finalized); + assert_eq!(finalized_workflow_id, workflow_id); + assert!(finalized_connection.connect_secret_is_consumed()); let audit = backend .record_request( @@ -789,6 +876,278 @@ mod tests { } #[test] + fn embedded_backend_delegates_lookup_state_and_auth_workflow_methods() { + let identity = embedded_identity(0xa0); + let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) + .expect("embedded backend"); + let backend_trait: &dyn RadrootsNostrSignerBackend = &backend; + + let connect_evaluation = backend_trait + .evaluate_connect_request( + synthetic_public_key(0xa1), + RadrootsNostrConnectRequest::Connect { + remote_signer_public_key: identity.public_key(), + secret: Some("connect-secret-2".into()), + requested_permissions: vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Ping, + )] + .into(), + }, + ) + .expect("connect evaluation"); + let connect_proposal = expect_registration_required(connect_evaluation); + let connection = backend_trait + .register_connection( + connect_proposal + .into_connection_draft(synthetic_public_identity(0xa2)) + .with_relays(vec![primary_relay()]), + ) + .expect("register connect-secret connection"); + + assert_eq!(backend_trait.list_connections().expect("list").len(), 1); + assert_eq!( + backend_trait + .get_connection(&connection.connection_id) + .expect("get connection") + .expect("stored connection") + .connection_id, + connection.connection_id + ); + assert_eq!( + backend_trait + .find_connections_by_client_public_key(&connection.client_public_key) + .expect("find by client key") + .len(), + 1 + ); + assert_eq!( + backend_trait + .find_connection_by_connect_secret("connect-secret-2") + .expect("find by secret") + .expect("stored by secret") + .connection_id, + connection.connection_id + ); + let looked_up = expect_lookup_connection( + backend_trait + .lookup_session(&connection.client_public_key, Some("connect-secret-2")) + .expect("lookup session"), + ); + assert_eq!(looked_up.connection_id, connection.connection_id); + + let with_relays = backend_trait + .update_relays( + &connection.connection_id, + vec![primary_relay(), secondary_relay()], + ) + .expect("update relays"); + assert_eq!(with_relays.relays.len(), 2); + + let evaluation = backend_trait + .evaluate_request( + &connection.connection_id, + RadrootsNostrConnectRequestMessage::new( + "req-ping", + RadrootsNostrConnectRequest::Ping, + ), + ) + .expect("evaluate request"); + assert!(matches!( + evaluation.action, + RadrootsNostrSignerRequestAction::Allowed { .. } + )); + + let authenticated = backend_trait + .mark_authenticated(&connection.connection_id) + .expect("mark authenticated"); + assert!(authenticated.last_authenticated_at_unix.is_some()); + + let pending_connection = backend_trait + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + synthetic_public_key(0xab), + synthetic_public_identity(0xac), + ) + .with_requested_permissions( + vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Ping, + )] + .into(), + ) + .with_approval_requirement(RadrootsNostrSignerApprovalRequirement::ExplicitUser), + ) + .expect("register pending connection"); + let granted_permissions: radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions = + vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Ping, + )] + .into(); + let granted = backend_trait + .set_granted_permissions( + &pending_connection.connection_id, + granted_permissions.clone(), + ) + .expect("set granted permissions"); + assert_eq!(granted.connection_id, pending_connection.connection_id); + + let approved = backend_trait + .approve_connection(&pending_connection.connection_id, granted_permissions) + .expect("approve connection"); + assert_eq!(approved.status, RadrootsNostrSignerConnectionStatus::Active); + + let begun = backend_trait + .begin_connect_secret_publish_finalization(&connection.connection_id) + .expect("begin connect workflow"); + let workflow = begun.workflow().expect("begun workflow").clone(); + assert!(begun.finalized_connection().is_none()); + assert_eq!( + backend_trait + .list_publish_workflows() + .expect("list publish workflows") + .len(), + 1 + ); + assert_eq!( + backend_trait + .get_publish_workflow(&workflow.workflow_id) + .expect("get publish workflow") + .expect("stored workflow") + .workflow_id, + workflow.workflow_id + ); + + let published = backend_trait + .mark_publish_workflow_published(&workflow.workflow_id) + .expect("mark publish workflow"); + assert_eq!( + published + .workflow() + .expect("published workflow") + .workflow_id, + workflow.workflow_id + ); + + let finalized = backend_trait + .finalize_publish_workflow(&workflow.workflow_id) + .expect("finalize workflow"); + assert!(finalized.workflow().is_none()); + assert_eq!( + finalized + .finalized_connection() + .expect("finalized connection") + .connection_id, + connection.connection_id + ); + + let audit = backend_trait + .record_request( + &connection.connection_id, + "req-audit", + RadrootsNostrConnectMethod::Ping, + RadrootsNostrSignerRequestDecision::Allowed, + None, + ) + .expect("record request"); + assert_eq!(audit.connection_id, connection.connection_id); + + let consumed_connection = backend_trait + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + synthetic_public_key(0xa3), + synthetic_public_identity(0xa4), + ) + .with_connect_secret("manual-secret"), + ) + .expect("register consumed connection"); + let consumed = backend_trait + .mark_connect_secret_consumed(&consumed_connection.connection_id) + .expect("mark connect secret consumed"); + assert!(consumed.connect_secret_is_consumed()); + + let rejected = backend_trait + .register_connection(RadrootsNostrSignerConnectionDraft::new( + synthetic_public_key(0xa5), + synthetic_public_identity(0xa6), + )) + .expect("register rejected connection"); + let rejected = backend_trait + .reject_connection(&rejected.connection_id, Some("rejected".into())) + .expect("reject connection"); + assert_eq!( + rejected.status, + RadrootsNostrSignerConnectionStatus::Rejected + ); + + let auth_connection = backend_trait + .register_connection( + RadrootsNostrSignerConnectionDraft::new( + synthetic_public_key(0xa7), + synthetic_public_identity(0xa8), + ) + .with_requested_permissions( + vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Ping, + )] + .into(), + ), + ) + .expect("register auth connection"); + backend_trait + .require_auth_challenge( + &auth_connection.connection_id, + "https://api.example.com/auth", + ) + .expect("require auth challenge"); + let pending = backend_trait + .set_pending_request( + &auth_connection.connection_id, + RadrootsNostrConnectRequestMessage::new( + "req-auth-replay", + RadrootsNostrConnectRequest::Ping, + ), + ) + .expect("set pending request"); + assert!(pending.pending_request.is_some()); + let authorized = backend_trait + .authorize_auth_challenge(&auth_connection.connection_id) + .expect("authorize auth challenge"); + let pending_request = authorized.pending_request.expect("pending request"); + let restored = backend_trait + .restore_pending_auth_challenge(&auth_connection.connection_id, pending_request.clone()) + .expect("restore pending auth challenge"); + assert_eq!(restored.pending_request.as_ref(), Some(&pending_request)); + + let auth_workflow = backend_trait + .begin_auth_replay_publish_finalization(&auth_connection.connection_id) + .expect("begin auth replay") + .workflow() + .expect("auth replay workflow") + .clone(); + let replay_evaluation = backend_trait + .evaluate_auth_replay_publish_workflow(&auth_workflow.workflow_id) + .expect("evaluate auth replay workflow"); + assert_eq!( + replay_evaluation.connection.connection_id, + auth_connection.connection_id + ); + let cancelled = backend_trait + .cancel_publish_workflow(&auth_workflow.workflow_id) + .expect("cancel auth workflow"); + assert_eq!( + cancelled + .workflow() + .expect("cancelled workflow") + .workflow_id, + auth_workflow.workflow_id + ); + + let revoked = backend_trait + .revoke_connection(&auth_connection.connection_id, Some("revoked".into())) + .expect("revoke connection"); + assert_eq!(revoked.status, RadrootsNostrSignerConnectionStatus::Revoked); + } + + #[test] fn embedded_backend_signs_builder_with_local_capability() { let identity = embedded_identity(0x95); let backend = RadrootsNostrEmbeddedSignerBackend::new_in_memory(identity.clone()) @@ -855,4 +1214,90 @@ mod tests { RadrootsNostrSignerPublishTransition::Cancelled(_) )); } + + #[test] + fn backend_capabilities_all_signers_supports_remote_only_and_identity_helpers() { + let remote = crate::capability::RadrootsNostrRemoteSessionSignerCapability::new( + crate::model::RadrootsNostrSignerConnectionId::new_v7(), + synthetic_public_identity(0xb0), + synthetic_public_identity(0xb1), + ); + let capabilities = RadrootsNostrSignerBackendCapabilities::new(None, vec![remote.clone()]); + + assert_eq!( + capabilities.all_signers(), + vec![crate::capability::RadrootsNostrSignerCapability::RemoteSession(remote)] + ); + + let valid_identity = synthetic_public_identity(0xb2); + assert!(same_public_identity_key(&valid_identity, &valid_identity)); + assert_eq!( + parse_identity_public_key(&valid_identity).expect("valid public key"), + synthetic_public_key(0xb2) + ); + let mut valid_identity_with_different_hex = valid_identity.clone(); + valid_identity_with_different_hex.public_key_hex = + synthetic_public_identity(0xb3).public_key_hex; + assert!(!same_public_identity_key( + &valid_identity, + &valid_identity_with_different_hex + )); + + let invalid_identity: RadrootsIdentityPublic = serde_json::from_value(json!({ + "id": "not-a-public-key", + "public_key_hex": "not-a-public-key", + "public_key_npub": "npub1invalid" + })) + .expect("invalid identity payload"); + let error = parse_identity_public_key(&invalid_identity) + .err() + .expect("invalid public identity"); + assert!(error.to_string().contains("identity public key is invalid")); + } + + #[test] + fn backend_test_helpers_reject_unexpected_variants() { + let connection = RadrootsNostrSignerConnectionRecord::new( + crate::model::RadrootsNostrSignerConnectionId::new_v7(), + synthetic_public_identity(0xb4), + RadrootsNostrSignerConnectionDraft::new( + synthetic_public_key(0xb5), + synthetic_public_identity(0xb6), + ), + 1, + ); + let workflow = RadrootsNostrSignerPublishWorkflowRecord::new_connect_secret_finalization( + connection.connection_id.clone(), + 1, + ); + + assert!( + std::panic::catch_unwind(|| { + expect_registration_required( + RadrootsNostrSignerConnectEvaluation::ExistingConnection(connection.clone()), + ) + }) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| { + expect_lookup_connection(RadrootsNostrSignerSessionLookup::None) + }) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| { + expect_begun_workflow_id(RadrootsNostrSignerPublishTransition::cancelled( + workflow.clone(), + )) + }) + .is_err() + ); + assert!( + std::panic::catch_unwind(|| { + expect_finalized_transition(RadrootsNostrSignerPublishTransition::begun(workflow)) + }) + .is_err() + ); + } } diff --git a/crates/nostr_signer/src/capability.rs b/crates/nostr_signer/src/capability.rs @@ -165,7 +165,7 @@ mod tests { use crate::model::{RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionRecord}; use crate::test_support::{ fixture_alice_identity, fixture_bob_identity, fixture_carol_identity, - fixture_diego_public_key, primary_relay, + fixture_diego_public_key, primary_relay, secondary_relay, }; use radroots_identity::RadrootsIdentityPublic; use radroots_nostr_connect::prelude::{ @@ -224,6 +224,7 @@ mod tests { let capability = record.remote_session_capability(); assert_public_identity_matches(capability.public_identity(), &user_identity); + assert!(capability.local_account().is_none()); let remote = capability.remote_session().expect("remote capability"); assert_eq!(remote.connection_id, record.connection_id); assert_public_identity_matches(&remote.signer_identity, &signer_identity); @@ -250,4 +251,95 @@ mod tests { assert_eq!(capability.permissions.as_slice().len(), 1); assert_eq!(capability.relays.len(), 1); } + + #[test] + fn capability_equality_accounts_for_identity_fields_and_variant_kind() { + let alice = fixture_alice_identity(); + let mut alice_with_different_hex = alice.clone(); + alice_with_different_hex.public_key_hex = fixture_bob_identity().public_key_hex; + let mut alice_with_different_npub = alice.clone(); + alice_with_different_npub.public_key_npub = fixture_bob_identity().public_key_npub; + + let local = RadrootsNostrLocalSignerCapability::new( + alice.id.clone(), + alice.clone(), + RadrootsNostrLocalSignerAvailability::SecretBacked, + ); + let local_same = RadrootsNostrLocalSignerCapability::new( + alice.id.clone(), + alice.clone(), + RadrootsNostrLocalSignerAvailability::SecretBacked, + ); + let local_changed_account = RadrootsNostrLocalSignerCapability::new( + fixture_bob_identity().id, + alice.clone(), + RadrootsNostrLocalSignerAvailability::SecretBacked, + ); + let local_changed_availability = RadrootsNostrLocalSignerCapability::new( + alice.id.clone(), + alice.clone(), + RadrootsNostrLocalSignerAvailability::PublicOnly, + ); + let local_changed_hex = RadrootsNostrLocalSignerCapability::new( + alice.id.clone(), + alice_with_different_hex, + RadrootsNostrLocalSignerAvailability::SecretBacked, + ); + let local_changed = RadrootsNostrLocalSignerCapability::new( + alice.id.clone(), + alice_with_different_npub, + RadrootsNostrLocalSignerAvailability::SecretBacked, + ); + assert_eq!(local, local_same); + assert_ne!(local, local_changed_account); + assert_ne!(local, local_changed_availability); + assert_ne!(local, local_changed_hex); + assert_ne!(local, local_changed); + + let remote = RadrootsNostrRemoteSessionSignerCapability::new( + RadrootsNostrSignerConnectionId::new_v7(), + fixture_bob_identity(), + fixture_carol_identity(), + ) + .with_relays(vec![primary_relay()]); + let remote_same = remote.clone(); + let remote_changed_connection = RadrootsNostrRemoteSessionSignerCapability::new( + RadrootsNostrSignerConnectionId::new_v7(), + remote.signer_identity.clone(), + remote.user_identity.clone(), + ) + .with_relays(remote.relays.clone()) + .with_permissions(remote.permissions.clone()); + let remote_changed_relays = remote.clone().with_relays(vec![secondary_relay()]); + let remote_changed_permissions = remote.clone().with_permissions( + vec![RadrootsNostrConnectPermission::new( + RadrootsNostrConnectMethod::Ping, + )] + .into(), + ); + let mut remote_changed_signer = remote.clone(); + remote_changed_signer.signer_identity.public_key_hex = + fixture_alice_identity().public_key_hex; + let mut remote_changed = remote.clone(); + remote_changed.user_identity.public_key_npub = fixture_alice_identity().public_key_npub; + assert_eq!(remote, remote_same); + assert_ne!(remote, remote_changed_connection); + assert_ne!(remote, remote_changed_relays); + assert_ne!(remote, remote_changed_permissions); + assert_ne!(remote, remote_changed_signer); + assert_ne!(remote, remote_changed); + + assert_eq!( + RadrootsNostrSignerCapability::LocalAccount(local.clone()), + RadrootsNostrSignerCapability::LocalAccount(local_same) + ); + assert_eq!( + RadrootsNostrSignerCapability::RemoteSession(remote.clone()), + RadrootsNostrSignerCapability::RemoteSession(remote) + ); + assert_ne!( + RadrootsNostrSignerCapability::LocalAccount(local), + RadrootsNostrSignerCapability::RemoteSession(remote_changed) + ); + } } diff --git a/crates/nostr_signer/src/error.rs b/crates/nostr_signer/src/error.rs @@ -78,4 +78,20 @@ mod tests { let converted: RadrootsNostrSignerError = source.into(); assert!(converted.to_string().starts_with("store error:")); } + + #[test] + fn converts_serde_json_error() { + let source = serde_json::from_str::<serde_json::Value>("{not-json") + .err() + .expect("serde error"); + let converted: RadrootsNostrSignerError = source.into(); + assert!(converted.to_string().starts_with("store error:")); + } + + #[cfg(feature = "native")] + #[test] + fn converts_sql_error() { + let converted: RadrootsNostrSignerError = radroots_sql_core::SqlError::Internal.into(); + assert!(converted.to_string().starts_with("store error:")); + } }