commit 28ba80f395ec6ffa1c5a9ea2eef2c1e91ac29764
parent a344de4d5c51e40906184993beb1ac16ebcf2e97
Author: triesap <tyson@radroots.org>
Date: Fri, 27 Mar 2026 02:39:11 +0000
persistence: add restore verification drills
Diffstat:
4 files changed, 617 insertions(+), 4 deletions(-)
diff --git a/src/cli.rs b/src/cli.rs
@@ -24,7 +24,9 @@ use crate::operability::{
collect_metrics, collect_status_full, collect_status_summary, increment_outcome_counts,
is_aggregate_publish_operation, operation_kind_label, render_metrics_text,
};
-use crate::persistence::{MycPersistenceImportSelection, import_json_to_sqlite};
+use crate::persistence::{
+ MycPersistenceImportSelection, import_json_to_sqlite, verify_restored_state,
+};
#[derive(Debug, Parser)]
#[command(name = "myc")]
@@ -93,6 +95,7 @@ pub enum MycPersistenceCommand {
#[arg(long)]
runtime_audit: bool,
},
+ VerifyRestore,
}
#[derive(Debug, Subcommand)]
@@ -378,6 +381,10 @@ pub async fn run_from_env() -> Result<(), MycError> {
)?;
print_json(&output)
}
+ MycPersistenceCommand::VerifyRestore => {
+ let output = verify_restored_state(&config)?;
+ print_json(&output)
+ }
},
MycCommand::Custody { command } => {
let provider = custody_provider_for_command(&config, &command)?;
diff --git a/src/lib.rs b/src/lib.rs
@@ -62,8 +62,10 @@ pub use outbox::{
};
pub use outbox_sqlite::MycSqliteDeliveryOutboxStore;
pub use persistence::{
- MycPersistenceImportJsonToSqliteOutput, MycPersistenceImportSelection,
- MycRuntimeAuditImportOutput, MycSignerStateImportOutput, import_json_to_sqlite,
+ MycDeliveryOutboxVerifyRestoreOutput, MycPersistenceImportJsonToSqliteOutput,
+ MycPersistenceImportSelection, MycPersistenceVerifyRestoreOutput, MycRuntimeAuditImportOutput,
+ MycRuntimeAuditVerifyRestoreOutput, MycSignerStateImportOutput,
+ MycSignerStateVerifyRestoreOutput, import_json_to_sqlite, verify_restored_state,
};
pub use policy::{MycConnectDecision, MycPolicyContext};
pub use transport::{MycNostrTransport, MycRelayPublishResult, MycTransportSnapshot};
diff --git a/src/persistence.rs b/src/persistence.rs
@@ -1,8 +1,13 @@
+use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::PathBuf;
+use nostr::PublicKey;
use radroots_nostr_signer::prelude::{
- RadrootsNostrFileSignerStore, RadrootsNostrSignerStore, RadrootsNostrSqliteSignerStore,
+ RadrootsNostrFileSignerStore, RadrootsNostrSignerAuthState,
+ RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerPublishWorkflowKind,
+ RadrootsNostrSignerPublishWorkflowRecord, RadrootsNostrSignerPublishWorkflowState,
+ RadrootsNostrSignerStore, RadrootsNostrSignerStoreState, RadrootsNostrSqliteSignerStore,
};
use serde::Serialize;
@@ -12,6 +17,10 @@ use crate::audit_sqlite::MycSqliteOperationAuditStore;
use crate::config::{MycConfig, MycRuntimeAuditBackend, MycSignerStateBackend};
use crate::custody::MycIdentityProvider;
use crate::error::MycError;
+use crate::outbox::{
+ MycDeliveryOutboxKind, MycDeliveryOutboxRecord, MycDeliveryOutboxStatus, MycDeliveryOutboxStore,
+};
+use crate::outbox_sqlite::MycSqliteDeliveryOutboxStore;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MycPersistenceImportSelection {
@@ -44,6 +53,44 @@ pub struct MycRuntimeAuditImportOutput {
pub record_count: usize,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycPersistenceVerifyRestoreOutput {
+ pub signer_identity_id: String,
+ pub user_identity_id: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub discovery_app_identity_id: Option<String>,
+ pub signer_state: MycSignerStateVerifyRestoreOutput,
+ pub runtime_audit: MycRuntimeAuditVerifyRestoreOutput,
+ pub delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycSignerStateVerifyRestoreOutput {
+ pub backend: MycSignerStateBackend,
+ pub path: PathBuf,
+ pub connection_count: usize,
+ pub request_audit_count: usize,
+ pub publish_workflow_count: usize,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycRuntimeAuditVerifyRestoreOutput {
+ pub backend: MycRuntimeAuditBackend,
+ pub path: PathBuf,
+ pub record_count: usize,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
+pub struct MycDeliveryOutboxVerifyRestoreOutput {
+ pub path: PathBuf,
+ pub total_job_count: usize,
+ pub queued_job_count: usize,
+ pub published_pending_finalize_job_count: usize,
+ pub finalized_job_count: usize,
+ pub failed_job_count: usize,
+ pub unfinished_job_count: usize,
+}
+
impl MycPersistenceImportSelection {
pub fn new(import_signer_state: bool, import_runtime_audit: bool) -> Self {
Self {
@@ -124,6 +171,122 @@ pub fn import_json_to_sqlite(
Ok(output)
}
+pub fn verify_restored_state(
+ config: &MycConfig,
+) -> Result<MycPersistenceVerifyRestoreOutput, MycError> {
+ config.validate()?;
+
+ let state_dir = &config.paths.state_dir;
+ let audit_dir = MycRuntimePaths::audit_dir_for_state_dir(state_dir);
+ let signer_state_path = MycRuntimePaths::signer_state_path_for_backend(
+ state_dir,
+ config.persistence.signer_state_backend,
+ );
+ let runtime_audit_path = MycRuntimePaths::runtime_audit_path_for_backend(
+ &audit_dir,
+ config.persistence.runtime_audit_backend,
+ );
+ let delivery_outbox_path = MycRuntimePaths::delivery_outbox_path_for_state_dir(state_dir);
+
+ require_existing_restore_file(
+ &signer_state_path,
+ format!(
+ "{} signer-state backend",
+ config.persistence.signer_state_backend.as_str()
+ ),
+ )?;
+ require_existing_restore_file(
+ &runtime_audit_path,
+ format!(
+ "{} runtime-audit backend",
+ config.persistence.runtime_audit_backend.as_str()
+ ),
+ )?;
+ require_existing_restore_file(&delivery_outbox_path, "delivery outbox".to_owned())?;
+
+ let signer_identity_provider =
+ MycIdentityProvider::from_source("signer", config.paths.signer_identity_source())?;
+ let signer_identity = signer_identity_provider.load_active_identity()?;
+ let user_identity_provider =
+ MycIdentityProvider::from_source("user", config.paths.user_identity_source())?;
+ let user_identity = user_identity_provider.load_active_identity()?;
+ let discovery_app_identity = match config.discovery.app_identity_source() {
+ Some(source) => Some(MycIdentityProvider::from_source("discovery app", source)?),
+ None => None,
+ }
+ .map(|provider| provider.load_active_identity())
+ .transpose()?;
+
+ let signer_state = load_existing_signer_state(config, &signer_state_path)?;
+ let configured_signer_identity = signer_identity.to_public();
+ if let Some(existing_signer_identity) = signer_state.signer_identity.as_ref() {
+ if existing_signer_identity.id != configured_signer_identity.id {
+ return Err(MycError::SignerIdentityMismatch {
+ identity_path: config.paths.signer_identity_path.clone(),
+ state_path: signer_state_path.clone(),
+ configured_identity_id: configured_signer_identity.id.to_string(),
+ persisted_identity_id: existing_signer_identity.id.to_string(),
+ });
+ }
+ }
+
+ let runtime_audit_record_count = load_existing_runtime_audit_record_count(config, &audit_dir)?;
+ let outbox_store = MycSqliteDeliveryOutboxStore::open(state_dir)?;
+ let outbox_records = outbox_store.list_all()?;
+ verify_restored_delivery_state(
+ &signer_state,
+ &outbox_records,
+ signer_identity.public_key(),
+ discovery_app_identity
+ .as_ref()
+ .map(|identity| identity.public_key()),
+ )?;
+
+ let mut queued_job_count = 0usize;
+ let mut published_pending_finalize_job_count = 0usize;
+ let mut finalized_job_count = 0usize;
+ let mut failed_job_count = 0usize;
+ for record in &outbox_records {
+ match record.status {
+ MycDeliveryOutboxStatus::Queued => queued_job_count += 1,
+ MycDeliveryOutboxStatus::PublishedPendingFinalize => {
+ published_pending_finalize_job_count += 1
+ }
+ MycDeliveryOutboxStatus::Finalized => finalized_job_count += 1,
+ MycDeliveryOutboxStatus::Failed => failed_job_count += 1,
+ }
+ }
+
+ Ok(MycPersistenceVerifyRestoreOutput {
+ signer_identity_id: signer_identity.id().to_string(),
+ user_identity_id: user_identity.id().to_string(),
+ discovery_app_identity_id: discovery_app_identity
+ .as_ref()
+ .map(|identity| identity.id().to_string()),
+ signer_state: MycSignerStateVerifyRestoreOutput {
+ backend: config.persistence.signer_state_backend,
+ path: signer_state_path,
+ connection_count: signer_state.connections.len(),
+ request_audit_count: signer_state.audit_records.len(),
+ publish_workflow_count: signer_state.publish_workflows.len(),
+ },
+ runtime_audit: MycRuntimeAuditVerifyRestoreOutput {
+ backend: config.persistence.runtime_audit_backend,
+ path: runtime_audit_path,
+ record_count: runtime_audit_record_count,
+ },
+ delivery_outbox: MycDeliveryOutboxVerifyRestoreOutput {
+ path: delivery_outbox_path,
+ total_job_count: outbox_records.len(),
+ queued_job_count,
+ published_pending_finalize_job_count,
+ finalized_job_count,
+ failed_job_count,
+ unfinished_job_count: queued_job_count + published_pending_finalize_job_count,
+ },
+ })
+}
+
fn import_signer_state_json_to_sqlite(
config: &MycConfig,
) -> Result<MycSignerStateImportOutput, MycError> {
@@ -206,6 +369,271 @@ fn signer_store_state_is_empty(
&& state.audit_records.is_empty()
}
+fn require_existing_restore_file(path: &std::path::Path, label: String) -> Result<(), MycError> {
+ if path.is_file() {
+ return Ok(());
+ }
+ Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore requires an existing {label} file at {}",
+ path.display()
+ )))
+}
+
+fn load_existing_signer_state(
+ config: &MycConfig,
+ signer_state_path: &std::path::Path,
+) -> Result<RadrootsNostrSignerStoreState, MycError> {
+ match config.persistence.signer_state_backend {
+ MycSignerStateBackend::JsonFile => RadrootsNostrFileSignerStore::new(signer_state_path)
+ .load()
+ .map_err(MycError::from),
+ MycSignerStateBackend::Sqlite => RadrootsNostrSqliteSignerStore::open(signer_state_path)?
+ .load()
+ .map_err(MycError::from),
+ }
+}
+
+fn load_existing_runtime_audit_record_count(
+ config: &MycConfig,
+ audit_dir: &std::path::Path,
+) -> Result<usize, MycError> {
+ match config.persistence.runtime_audit_backend {
+ MycRuntimeAuditBackend::JsonlFile => Ok(MycJsonlOperationAuditStore::new(
+ audit_dir,
+ config.audit.clone(),
+ )
+ .list_all()?
+ .len()),
+ MycRuntimeAuditBackend::Sqlite => Ok(MycSqliteOperationAuditStore::open(
+ audit_dir,
+ config.audit.clone(),
+ )?
+ .list_all()?
+ .len()),
+ }
+}
+
+fn verify_restored_delivery_state(
+ signer_state: &RadrootsNostrSignerStoreState,
+ outbox_records: &[MycDeliveryOutboxRecord],
+ signer_public_key: PublicKey,
+ discovery_app_public_key: Option<PublicKey>,
+) -> Result<(), MycError> {
+ let connections_by_id = signer_state
+ .connections
+ .iter()
+ .map(|connection| (connection.connection_id.as_str().to_owned(), connection))
+ .collect::<BTreeMap<_, _>>();
+ let workflows_by_id = signer_state
+ .publish_workflows
+ .iter()
+ .map(|workflow| (workflow.workflow_id.as_str().to_owned(), workflow))
+ .collect::<BTreeMap<_, _>>();
+ let mut referenced_unfinished_workflow_ids = BTreeSet::new();
+
+ for record in outbox_records {
+ verify_discovery_restore_author(record, signer_public_key, discovery_app_public_key)?;
+
+ if !matches!(
+ record.status,
+ MycDeliveryOutboxStatus::Queued | MycDeliveryOutboxStatus::PublishedPendingFinalize
+ ) {
+ continue;
+ }
+
+ let workflow = match record.signer_publish_workflow_id.as_ref() {
+ Some(workflow_id) => {
+ referenced_unfinished_workflow_ids.insert(workflow_id.as_str().to_owned());
+ workflows_by_id.get(workflow_id.as_str()).copied()
+ }
+ None => None,
+ };
+
+ verify_restore_outbox_record(record, workflow, &connections_by_id)?;
+ }
+
+ let orphaned_workflows = signer_state
+ .publish_workflows
+ .iter()
+ .filter(|workflow| {
+ !referenced_unfinished_workflow_ids.contains(workflow.workflow_id.as_str())
+ })
+ .map(|workflow| {
+ format!(
+ "{}:{}:{:?}",
+ workflow.workflow_id, workflow.connection_id, workflow.kind
+ )
+ })
+ .collect::<Vec<_>>();
+ if !orphaned_workflows.is_empty() {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found orphaned signer publish workflows with no unfinished delivery outbox job: {}",
+ orphaned_workflows.join(", ")
+ )));
+ }
+
+ Ok(())
+}
+
+fn verify_discovery_restore_author(
+ record: &MycDeliveryOutboxRecord,
+ signer_public_key: PublicKey,
+ discovery_app_public_key: Option<PublicKey>,
+) -> Result<(), MycError> {
+ if record.kind != MycDeliveryOutboxKind::DiscoveryHandlerPublish {
+ return Ok(());
+ }
+ if record.event.pubkey == signer_public_key
+ || discovery_app_public_key == Some(record.event.pubkey)
+ {
+ return Ok(());
+ }
+
+ Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found discovery delivery outbox job `{}` authored by `{}` but the configured signer/discovery identities do not match",
+ record.job_id, record.event.pubkey
+ )))
+}
+
+fn verify_restore_outbox_record<'a>(
+ record: &MycDeliveryOutboxRecord,
+ workflow: Option<&'a RadrootsNostrSignerPublishWorkflowRecord>,
+ connections_by_id: &BTreeMap<String, &'a RadrootsNostrSignerConnectionRecord>,
+) -> Result<(), MycError> {
+ match record.kind {
+ MycDeliveryOutboxKind::DiscoveryHandlerPublish => {
+ if record.signer_publish_workflow_id.is_some() {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found discovery delivery outbox job `{}` that incorrectly references a signer publish workflow",
+ record.job_id
+ )));
+ }
+ }
+ MycDeliveryOutboxKind::ConnectAcceptPublish | MycDeliveryOutboxKind::AuthReplayPublish => {
+ if record.signer_publish_workflow_id.is_none() {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found control delivery outbox job `{}` without a signer publish workflow",
+ record.job_id
+ )));
+ }
+ }
+ MycDeliveryOutboxKind::ListenerResponsePublish => {}
+ }
+
+ match workflow {
+ Some(workflow) => {
+ let expected_kind = match record.kind {
+ MycDeliveryOutboxKind::ListenerResponsePublish
+ | MycDeliveryOutboxKind::ConnectAcceptPublish => {
+ RadrootsNostrSignerPublishWorkflowKind::ConnectSecretFinalization
+ }
+ MycDeliveryOutboxKind::AuthReplayPublish => {
+ RadrootsNostrSignerPublishWorkflowKind::AuthReplayFinalization
+ }
+ MycDeliveryOutboxKind::DiscoveryHandlerPublish => unreachable!(),
+ };
+ if workflow.kind != expected_kind {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` expecting signer workflow kind `{:?}` but found `{:?}`",
+ record.job_id, expected_kind, workflow.kind
+ )));
+ }
+
+ let connection_id = record.connection_id.as_ref().ok_or_else(|| {
+ MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` missing a connection id required for signer workflow verification",
+ record.job_id
+ ))
+ })?;
+ if workflow.connection_id.as_str() != connection_id.as_str() {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` bound to connection `{connection_id}` but signer workflow `{}` is bound to `{}`",
+ record.job_id, workflow.workflow_id, workflow.connection_id
+ )));
+ }
+ if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize
+ && workflow.state
+ != RadrootsNostrSignerPublishWorkflowState::PublishedPendingFinalize
+ {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` waiting for finalize but signer workflow `{}` is in `{:?}`",
+ record.job_id, workflow.workflow_id, workflow.state
+ )));
+ }
+ }
+ None => {
+ if record.signer_publish_workflow_id.is_some() {
+ if record.status == MycDeliveryOutboxStatus::PublishedPendingFinalize {
+ verify_already_finalized_without_workflow(record, connections_by_id)?;
+ } else {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` referencing a missing signer publish workflow before finalize",
+ record.job_id
+ )));
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn verify_already_finalized_without_workflow(
+ record: &MycDeliveryOutboxRecord,
+ connections_by_id: &BTreeMap<String, &RadrootsNostrSignerConnectionRecord>,
+) -> Result<(), MycError> {
+ let workflow_id = record.signer_publish_workflow_id.as_ref().ok_or_else(|| {
+ MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` missing a signer workflow id for finalization verification",
+ record.job_id
+ ))
+ })?;
+ let connection_id = record.connection_id.as_ref().ok_or_else(|| {
+ MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` missing a connection id for finalization verification",
+ record.job_id
+ ))
+ })?;
+ let connection = connections_by_id
+ .get(connection_id.as_str())
+ .copied()
+ .ok_or_else(|| {
+ MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` referencing missing connection `{connection_id}`",
+ record.job_id
+ ))
+ })?;
+
+ match record.kind {
+ MycDeliveryOutboxKind::ListenerResponsePublish
+ | MycDeliveryOutboxKind::ConnectAcceptPublish => {
+ if !connection.connect_secret_is_consumed() {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` referencing connect workflow `{workflow_id}` but the connection secret is still reusable",
+ record.job_id
+ )));
+ }
+ }
+ MycDeliveryOutboxKind::AuthReplayPublish => {
+ if connection.auth_state != RadrootsNostrSignerAuthState::Authorized
+ || connection.pending_request.is_some()
+ {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found delivery outbox job `{}` referencing auth replay workflow `{workflow_id}` but the connection auth state is not finalized",
+ record.job_id
+ )));
+ }
+ }
+ MycDeliveryOutboxKind::DiscoveryHandlerPublish => {
+ return Err(MycError::InvalidOperation(format!(
+ "persistence verify-restore found discovery delivery outbox job `{}` unexpectedly referencing signer workflow `{workflow_id}`",
+ record.job_id
+ )));
+ }
+ }
+
+ Ok(())
+}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
diff --git a/tests/persistence_cli.rs b/tests/persistence_cli.rs
@@ -17,6 +17,20 @@ fn write_identity(path: &Path, secret_key: &str) {
.expect("save identity");
}
+fn copy_dir_recursive(source: &Path, destination: &Path) {
+ std::fs::create_dir_all(destination).expect("create copied dir");
+ for entry in std::fs::read_dir(source).expect("read copied dir source") {
+ let entry = entry.expect("dir entry");
+ let source_path = entry.path();
+ let destination_path = destination.join(entry.file_name());
+ if source_path.is_dir() {
+ copy_dir_recursive(&source_path, &destination_path);
+ } else {
+ std::fs::copy(&source_path, &destination_path).expect("copy file");
+ }
+ }
+}
+
fn bootstrap_populated_json_runtime(temp: &tempfile::TempDir) -> (MycConfig, MycConfig) {
let mut json_config = MycConfig::default();
json_config.paths.state_dir = temp.path().join("state");
@@ -58,6 +72,27 @@ fn bootstrap_populated_json_runtime(temp: &tempfile::TempDir) -> (MycConfig, Myc
(json_config, sqlite_config)
}
+fn migrate_to_sqlite(temp: &tempfile::TempDir) -> MycConfig {
+ let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(temp);
+ let env_path = temp.path().join("myc-sqlite.env");
+ std::fs::write(
+ &env_path,
+ sqlite_config.to_env_string().expect("render sqlite env"),
+ )
+ .expect("write sqlite env");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_myc"))
+ .arg("--env-file")
+ .arg(&env_path)
+ .arg("persistence")
+ .arg("import-json-to-sqlite")
+ .output()
+ .expect("run import");
+ assert!(output.status.success(), "{:?}", output);
+
+ sqlite_config
+}
+
#[test]
fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -127,3 +162,144 @@ fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() {
let stderr = String::from_utf8(rerun.stderr).expect("rerun stderr");
assert!(stderr.contains("sqlite signer-state destination"));
}
+
+#[test]
+fn persistence_verify_restore_cli_accepts_copied_sqlite_state() {
+ let source = tempfile::tempdir().expect("source tempdir");
+ let sqlite_config = migrate_to_sqlite(&source);
+
+ let restored = tempfile::tempdir().expect("restored tempdir");
+ let restored_state_dir = restored.path().join("state");
+ copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir);
+ let restored_signer = restored.path().join("signer.json");
+ let restored_user = restored.path().join("user.json");
+ std::fs::copy(&sqlite_config.paths.signer_identity_path, &restored_signer)
+ .expect("copy signer identity");
+ std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user)
+ .expect("copy user identity");
+
+ let mut restored_config = sqlite_config.clone();
+ restored_config.paths.state_dir = restored_state_dir;
+ restored_config.paths.signer_identity_path = restored_signer;
+ restored_config.paths.user_identity_path = restored_user;
+ let restored_env = restored.path().join("restored.env");
+ std::fs::write(
+ &restored_env,
+ restored_config
+ .to_env_string()
+ .expect("render restored env"),
+ )
+ .expect("write restored env");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_myc"))
+ .arg("--env-file")
+ .arg(&restored_env)
+ .arg("persistence")
+ .arg("verify-restore")
+ .output()
+ .expect("run verify restore");
+
+ assert!(output.status.success(), "{:?}", output);
+
+ let parsed: Value = serde_json::from_slice(&output.stdout).expect("verify restore json");
+ assert_eq!(parsed["signer_state"]["backend"], "sqlite");
+ assert_eq!(parsed["signer_state"]["connection_count"], 1);
+ assert_eq!(parsed["runtime_audit"]["backend"], "sqlite");
+ assert_eq!(parsed["runtime_audit"]["record_count"], 1);
+ assert_eq!(parsed["delivery_outbox"]["queued_job_count"], 0);
+ assert_eq!(parsed["delivery_outbox"]["unfinished_job_count"], 0);
+ assert!(
+ parsed["delivery_outbox"]["path"]
+ .as_str()
+ .expect("delivery outbox path")
+ .ends_with("delivery-outbox.sqlite")
+ );
+}
+
+#[test]
+fn persistence_verify_restore_cli_rejects_missing_outbox_file() {
+ let source = tempfile::tempdir().expect("source tempdir");
+ let sqlite_config = migrate_to_sqlite(&source);
+
+ let restored = tempfile::tempdir().expect("restored tempdir");
+ let restored_state_dir = restored.path().join("state");
+ copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir);
+ let restored_signer = restored.path().join("signer.json");
+ let restored_user = restored.path().join("user.json");
+ std::fs::copy(&sqlite_config.paths.signer_identity_path, &restored_signer)
+ .expect("copy signer identity");
+ std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user)
+ .expect("copy user identity");
+ std::fs::remove_file(restored_state_dir.join("delivery-outbox.sqlite"))
+ .expect("remove restored outbox");
+
+ let mut restored_config = sqlite_config.clone();
+ restored_config.paths.state_dir = restored_state_dir;
+ restored_config.paths.signer_identity_path = restored_signer;
+ restored_config.paths.user_identity_path = restored_user;
+ let restored_env = restored.path().join("restored.env");
+ std::fs::write(
+ &restored_env,
+ restored_config
+ .to_env_string()
+ .expect("render restored env"),
+ )
+ .expect("write restored env");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_myc"))
+ .arg("--env-file")
+ .arg(&restored_env)
+ .arg("persistence")
+ .arg("verify-restore")
+ .output()
+ .expect("run verify restore");
+
+ assert!(!output.status.success(), "{:?}", output);
+ let stderr = String::from_utf8(output.stderr).expect("verify restore stderr");
+ assert!(
+ stderr.contains("persistence verify-restore requires an existing delivery outbox file")
+ );
+}
+
+#[test]
+fn persistence_verify_restore_cli_rejects_signer_identity_mismatch() {
+ let source = tempfile::tempdir().expect("source tempdir");
+ let sqlite_config = migrate_to_sqlite(&source);
+
+ let restored = tempfile::tempdir().expect("restored tempdir");
+ let restored_state_dir = restored.path().join("state");
+ copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir);
+ let restored_signer = restored.path().join("other-signer.json");
+ let restored_user = restored.path().join("user.json");
+ write_identity(
+ &restored_signer,
+ "3333333333333333333333333333333333333333333333333333333333333333",
+ );
+ std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user)
+ .expect("copy user identity");
+
+ let mut restored_config = sqlite_config.clone();
+ restored_config.paths.state_dir = restored_state_dir;
+ restored_config.paths.signer_identity_path = restored_signer;
+ restored_config.paths.user_identity_path = restored_user;
+ let restored_env = restored.path().join("restored.env");
+ std::fs::write(
+ &restored_env,
+ restored_config
+ .to_env_string()
+ .expect("render restored env"),
+ )
+ .expect("write restored env");
+
+ let output = Command::new(env!("CARGO_BIN_EXE_myc"))
+ .arg("--env-file")
+ .arg(&restored_env)
+ .arg("persistence")
+ .arg("verify-restore")
+ .output()
+ .expect("run verify restore");
+
+ assert!(!output.status.success(), "{:?}", output);
+ let stderr = String::from_utf8(output.stderr).expect("verify restore stderr");
+ assert!(stderr.contains("does not match persisted signer identity"));
+}