commit 0c47dc077bec713a3a4523b4af8f5e99452455ee
parent b85da271483bc07f48274a8b992279220bde7203
Author: triesap <tyson@radroots.org>
Date: Fri, 27 Mar 2026 13:05:10 +0000
persistence: cover restore verification branches
Diffstat:
| M | src/persistence.rs | | | 327 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- |
1 file changed, 318 insertions(+), 9 deletions(-)
diff --git a/src/persistence.rs b/src/persistence.rs
@@ -641,20 +641,33 @@ mod tests {
use nostr::PublicKey;
use radroots_identity::RadrootsIdentity;
+ use radroots_nostr::prelude::{
+ RadrootsNostrEvent, RadrootsNostrEventBuilder, RadrootsNostrKind,
+ };
use radroots_nostr_signer::prelude::{
RADROOTS_NOSTR_SIGNER_STORE_VERSION, RadrootsNostrFileSignerStore,
RadrootsNostrSignerConnectionDraft, RadrootsNostrSignerConnectionId,
- RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSqliteSignerStore,
+ RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSignerWorkflowId,
+ RadrootsNostrSqliteSignerStore,
};
use super::{
MycPersistenceImportSelection, import_json_to_sqlite, signer_store_state_is_empty,
+ verify_restored_delivery_state,
};
use crate::app::MycRuntime;
use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord};
use crate::audit_sqlite::MycSqliteOperationAuditStore;
use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend};
use crate::error::MycError;
+ use crate::outbox::{MycDeliveryOutboxKind, MycDeliveryOutboxRecord};
+
+ const SIGNER_SECRET_KEY: &str =
+ "1111111111111111111111111111111111111111111111111111111111111111";
+ const USER_SECRET_KEY: &str =
+ "2222222222222222222222222222222222222222222222222222222222222222";
+ const OTHER_SECRET_KEY: &str =
+ "3333333333333333333333333333333333333333333333333333333333333333";
fn write_identity(path: &Path, secret_key: &str) {
RadrootsIdentity::from_secret_key_str(secret_key)
@@ -663,19 +676,60 @@ mod tests {
.expect("save identity");
}
+ fn identity(secret_key: &str) -> RadrootsIdentity {
+ RadrootsIdentity::from_secret_key_str(secret_key).expect("identity")
+ }
+
+ fn signer_identity() -> RadrootsIdentity {
+ identity(SIGNER_SECRET_KEY)
+ }
+
+ fn user_identity() -> RadrootsIdentity {
+ identity(USER_SECRET_KEY)
+ }
+
+ fn signed_event(secret_key: &str) -> RadrootsNostrEvent {
+ RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(24133), "hello")
+ .sign_with_keys(identity(secret_key).keys())
+ .expect("sign event")
+ }
+
+ fn outbox_record(kind: MycDeliveryOutboxKind, secret_key: &str) -> MycDeliveryOutboxRecord {
+ MycDeliveryOutboxRecord::new(
+ kind,
+ signed_event(secret_key),
+ vec!["wss://relay.example.com".parse().expect("relay")],
+ )
+ .expect("record")
+ }
+
+ fn client_public_key(value: &str) -> PublicKey {
+ PublicKey::from_hex(value).expect("pubkey")
+ }
+
+ fn load_json_signer_state(temp: &Path) -> RadrootsNostrSignerStoreState {
+ RadrootsNostrFileSignerStore::new(temp.join("state").join("signer-state.json"))
+ .load()
+ .expect("load signer state")
+ }
+
+ fn empty_signer_state() -> RadrootsNostrSignerStoreState {
+ RadrootsNostrSignerStoreState {
+ version: RADROOTS_NOSTR_SIGNER_STORE_VERSION,
+ signer_identity: None,
+ connections: Vec::new(),
+ audit_records: Vec::new(),
+ publish_workflows: Vec::new(),
+ }
+ }
+
fn base_config(temp: &Path) -> MycConfig {
let mut config = MycConfig::default();
config.paths.state_dir = temp.join("state");
config.paths.signer_identity_path = temp.join("signer.json");
config.paths.user_identity_path = temp.join("user.json");
- write_identity(
- &config.paths.signer_identity_path,
- "1111111111111111111111111111111111111111111111111111111111111111",
- );
- write_identity(
- &config.paths.user_identity_path,
- "2222222222222222222222222222222222222222222222222222222222222222",
- );
+ write_identity(&config.paths.signer_identity_path, SIGNER_SECRET_KEY);
+ write_identity(&config.paths.user_identity_path, USER_SECRET_KEY);
config
}
@@ -706,6 +760,261 @@ mod tests {
}
#[test]
+ fn verify_restore_rejects_orphaned_signer_publish_workflows() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let runtime = bootstrap_json_runtime(temp.path());
+ let manager = runtime.signer_manager().expect("manager");
+ let connection = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(
+ client_public_key(
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ ),
+ runtime.user_public_identity(),
+ )
+ .with_connect_secret("orphan-secret"),
+ )
+ .expect("register connection");
+ manager
+ .begin_connect_secret_publish_finalization(&connection.connection_id)
+ .expect("begin workflow");
+
+ let signer_state = load_json_signer_state(temp.path());
+ let err = verify_restored_delivery_state(
+ &signer_state,
+ &[],
+ signer_identity().public_key(),
+ None,
+ )
+ .expect_err("orphaned workflow should fail restore verification");
+
+ assert!(
+ err.to_string()
+ .contains("orphaned signer publish workflows")
+ );
+ }
+
+ #[test]
+ fn verify_restore_rejects_discovery_author_mismatch() {
+ let signer_state = empty_signer_state();
+ let record = outbox_record(
+ MycDeliveryOutboxKind::DiscoveryHandlerPublish,
+ OTHER_SECRET_KEY,
+ );
+
+ let err = verify_restored_delivery_state(
+ &signer_state,
+ &[record],
+ signer_identity().public_key(),
+ Some(user_identity().public_key()),
+ )
+ .expect_err("unexpected discovery author should fail restore verification");
+
+ assert!(
+ err.to_string()
+ .contains("configured signer/discovery identities do not match")
+ );
+ }
+
+ #[test]
+ fn verify_restore_rejects_missing_workflow_before_finalize() {
+ let signer_state = empty_signer_state();
+ let workflow_id =
+ RadrootsNostrSignerWorkflowId::parse("missing-workflow").expect("workflow id");
+ let record = outbox_record(
+ MycDeliveryOutboxKind::ListenerResponsePublish,
+ SIGNER_SECRET_KEY,
+ )
+ .with_signer_publish_workflow_id(&workflow_id);
+
+ let err = verify_restored_delivery_state(
+ &signer_state,
+ &[record],
+ signer_identity().public_key(),
+ None,
+ )
+ .expect_err("missing unfinished workflow should fail restore verification");
+
+ assert!(
+ err.to_string()
+ .contains("referencing a missing signer publish workflow before finalize")
+ );
+ }
+
+ #[test]
+ fn verify_restore_accepts_published_pending_finalize_job_after_connect_finalization() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let runtime = bootstrap_json_runtime(temp.path());
+ let manager = runtime.signer_manager().expect("manager");
+ let connection = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(
+ client_public_key(
+ "c6047f9441ed7d6d3045406e95c07cd85a65f77e53bde42a6d0f46b4f0f92b4f",
+ ),
+ runtime.user_public_identity(),
+ )
+ .with_connect_secret("accepted-secret"),
+ )
+ .expect("register connection");
+ let workflow = manager
+ .begin_connect_secret_publish_finalization(&connection.connection_id)
+ .expect("begin workflow");
+ manager
+ .mark_publish_workflow_published(&workflow.workflow_id)
+ .expect("mark published");
+ manager
+ .finalize_publish_workflow(&workflow.workflow_id)
+ .expect("finalize workflow");
+
+ let signer_state = load_json_signer_state(temp.path());
+ let mut record = outbox_record(
+ MycDeliveryOutboxKind::ListenerResponsePublish,
+ SIGNER_SECRET_KEY,
+ )
+ .with_connection_id(&connection.connection_id)
+ .with_signer_publish_workflow_id(&workflow.workflow_id);
+ record
+ .mark_published_pending_finalize(1, record.created_at_unix + 1)
+ .expect("mark published");
+
+ verify_restored_delivery_state(
+ &signer_state,
+ &[record],
+ signer_identity().public_key(),
+ None,
+ )
+ .expect("already-finalized connect workflow should be accepted");
+ }
+
+ #[test]
+ fn verify_restore_rejects_wrong_workflow_kind() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let runtime = bootstrap_json_runtime(temp.path());
+ let manager = runtime.signer_manager().expect("manager");
+ let connection = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(
+ client_public_key(
+ "f9308a019258c3106f85b9d5b3e8c8f923dc4bde7b5b6d8f8f9ad7881e5341e5",
+ ),
+ runtime.user_public_identity(),
+ )
+ .with_connect_secret("kind-secret"),
+ )
+ .expect("register connection");
+ let workflow = manager
+ .begin_connect_secret_publish_finalization(&connection.connection_id)
+ .expect("begin workflow");
+
+ let signer_state = load_json_signer_state(temp.path());
+ let record = outbox_record(MycDeliveryOutboxKind::AuthReplayPublish, SIGNER_SECRET_KEY)
+ .with_connection_id(&connection.connection_id)
+ .with_signer_publish_workflow_id(&workflow.workflow_id);
+
+ let err = verify_restored_delivery_state(
+ &signer_state,
+ &[record],
+ signer_identity().public_key(),
+ None,
+ )
+ .expect_err("workflow kind mismatch should fail restore verification");
+
+ assert!(err.to_string().contains("expecting signer workflow kind"));
+ }
+
+ #[test]
+ fn verify_restore_rejects_wrong_connection_binding() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let runtime = bootstrap_json_runtime(temp.path());
+ let manager = runtime.signer_manager().expect("manager");
+ let first = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(
+ client_public_key(
+ "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
+ ),
+ runtime.user_public_identity(),
+ )
+ .with_connect_secret("first-secret"),
+ )
+ .expect("register first");
+ let second = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(
+ client_public_key(
+ "c6047f9441ed7d6d3045406e95c07cd85a65f77e53bde42a6d0f46b4f0f92b4f",
+ ),
+ runtime.user_public_identity(),
+ )
+ .with_connect_secret("second-secret"),
+ )
+ .expect("register second");
+ let workflow = manager
+ .begin_connect_secret_publish_finalization(&first.connection_id)
+ .expect("begin workflow");
+
+ let signer_state = load_json_signer_state(temp.path());
+ let record = outbox_record(
+ MycDeliveryOutboxKind::ListenerResponsePublish,
+ SIGNER_SECRET_KEY,
+ )
+ .with_connection_id(&second.connection_id)
+ .with_signer_publish_workflow_id(&workflow.workflow_id);
+
+ let err = verify_restored_delivery_state(
+ &signer_state,
+ &[record],
+ signer_identity().public_key(),
+ None,
+ )
+ .expect_err("workflow connection mismatch should fail restore verification");
+
+ assert!(err.to_string().contains("is bound to"));
+ }
+
+ #[test]
+ fn verify_restore_rejects_missing_connection_id_for_workflow_job() {
+ let temp = tempfile::tempdir().expect("tempdir");
+ let runtime = bootstrap_json_runtime(temp.path());
+ let manager = runtime.signer_manager().expect("manager");
+ let connection = manager
+ .register_connection(
+ RadrootsNostrSignerConnectionDraft::new(
+ client_public_key(
+ "f9308a019258c3106f85b9d5b3e8c8f923dc4bde7b5b6d8f8f9ad7881e5341e5",
+ ),
+ runtime.user_public_identity(),
+ )
+ .with_connect_secret("missing-connection-id-secret"),
+ )
+ .expect("register connection");
+ let workflow = manager
+ .begin_connect_secret_publish_finalization(&connection.connection_id)
+ .expect("begin workflow");
+
+ let signer_state = load_json_signer_state(temp.path());
+ let record = outbox_record(
+ MycDeliveryOutboxKind::ListenerResponsePublish,
+ SIGNER_SECRET_KEY,
+ )
+ .with_signer_publish_workflow_id(&workflow.workflow_id);
+
+ let err = verify_restored_delivery_state(
+ &signer_state,
+ &[record],
+ signer_identity().public_key(),
+ None,
+ )
+ .expect_err("missing connection id should fail restore verification");
+
+ assert!(
+ err.to_string()
+ .contains("missing a connection id required for signer workflow verification")
+ );
+ }
+
+ #[test]
fn import_json_to_sqlite_moves_signer_state_and_runtime_audit() {
let temp = tempfile::tempdir().expect("tempdir");
let runtime = bootstrap_json_runtime(temp.path());