persistence_cli.rs (17951B)
1 use std::path::Path; 2 use std::process::Command; 3 4 use myc::{ 5 MycConfig, MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord, 6 MycRuntime, MycRuntimeAuditBackend, MycSignerStateBackend, 7 }; 8 use nostr::PublicKey; 9 use radroots_identity::RadrootsIdentity; 10 use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft; 11 use serde_json::Value; 12 13 fn write_identity(path: &Path, secret_key: &str) { 14 let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); 15 myc::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); 16 } 17 18 fn copy_dir_recursive(source: &Path, destination: &Path) { 19 std::fs::create_dir_all(destination).expect("create copied dir"); 20 for entry in std::fs::read_dir(source).expect("read copied dir source") { 21 let entry = entry.expect("dir entry"); 22 let source_path = entry.path(); 23 let destination_path = destination.join(entry.file_name()); 24 if source_path.is_dir() { 25 copy_dir_recursive(&source_path, &destination_path); 26 } else { 27 std::fs::copy(&source_path, &destination_path).expect("copy file"); 28 } 29 } 30 } 31 32 fn bootstrap_populated_json_runtime(temp: &tempfile::TempDir) -> (MycConfig, MycConfig) { 33 let mut json_config = MycConfig::default(); 34 json_config.paths.state_dir = temp.path().join("state"); 35 json_config.paths.signer_identity_path = temp.path().join("signer.json"); 36 json_config.paths.user_identity_path = temp.path().join("user.json"); 37 38 write_identity( 39 &json_config.paths.signer_identity_path, 40 "1111111111111111111111111111111111111111111111111111111111111111", 41 ); 42 write_identity( 43 &json_config.paths.user_identity_path, 44 "2222222222222222222222222222222222222222222222222222222222222222", 45 ); 46 47 let runtime = MycRuntime::bootstrap(json_config.clone()).expect("json runtime"); 48 let manager = runtime.signer_manager().expect("manager"); 49 let connection = manager 50 .register_connection(RadrootsNostrSignerConnectionDraft::new( 51 PublicKey::from_hex("79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798") 52 .expect("pubkey"), 53 runtime.user_public_identity(), 54 )) 55 .expect("register connection"); 56 runtime.record_operation_audit(&MycOperationAuditRecord::new( 57 MycOperationAuditKind::ListenerResponsePublish, 58 MycOperationAuditOutcome::Succeeded, 59 Some(&connection.connection_id), 60 Some("request-1"), 61 1, 62 1, 63 "publish succeeded", 64 )); 65 66 let mut sqlite_config = json_config.clone(); 67 sqlite_config.persistence.signer_state_backend = MycSignerStateBackend::Sqlite; 68 sqlite_config.persistence.runtime_audit_backend = MycRuntimeAuditBackend::Sqlite; 69 70 (json_config, sqlite_config) 71 } 72 73 fn migrate_to_sqlite(temp: &tempfile::TempDir) -> MycConfig { 74 let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(temp); 75 let env_path = temp.path().join("myc-sqlite.env"); 76 std::fs::write( 77 &env_path, 78 sqlite_config.to_env_string().expect("render sqlite env"), 79 ) 80 .expect("write sqlite env"); 81 82 let output = Command::new(env!("CARGO_BIN_EXE_myc")) 83 .arg("--env-file") 84 .arg(&env_path) 85 .arg("persistence") 86 .arg("import-json-to-sqlite") 87 .output() 88 .expect("run import"); 89 assert!(output.status.success(), "{:?}", output); 90 91 sqlite_config 92 } 93 94 fn write_env(path: &Path, config: &MycConfig) { 95 std::fs::write(path, config.to_env_string().expect("render env")).expect("write env"); 96 } 97 98 fn run_myc(env_path: &Path, args: &[&str]) -> std::process::Output { 99 let mut command = Command::new(env!("CARGO_BIN_EXE_myc")); 100 command.arg("--env-file").arg(env_path); 101 for arg in args { 102 command.arg(arg); 103 } 104 command.output().expect("run myc") 105 } 106 107 #[test] 108 fn persistence_import_json_to_sqlite_cli_migrates_state_and_rejects_rerun() { 109 let temp = tempfile::tempdir().expect("tempdir"); 110 let (_json_config, sqlite_config) = bootstrap_populated_json_runtime(&temp); 111 let env_path = temp.path().join("myc-sqlite.env"); 112 std::fs::write( 113 &env_path, 114 sqlite_config.to_env_string().expect("render sqlite env"), 115 ) 116 .expect("write env"); 117 118 let output = Command::new(env!("CARGO_BIN_EXE_myc")) 119 .arg("--env-file") 120 .arg(&env_path) 121 .arg("persistence") 122 .arg("import-json-to-sqlite") 123 .output() 124 .expect("run import"); 125 126 assert!(output.status.success(), "{:?}", output); 127 128 let parsed: Value = serde_json::from_slice(&output.stdout).expect("import json"); 129 assert_eq!(parsed["signer_state"]["connection_count"], 1); 130 assert_eq!(parsed["signer_state"]["request_audit_count"], 0); 131 assert_eq!(parsed["runtime_audit"]["record_count"], 1); 132 assert!( 133 parsed["signer_state"]["destination_path"] 134 .as_str() 135 .expect("sqlite signer destination") 136 .ends_with("signer-state.sqlite") 137 ); 138 assert!( 139 parsed["runtime_audit"]["destination_path"] 140 .as_str() 141 .expect("sqlite audit destination") 142 .ends_with("operations.sqlite") 143 ); 144 145 let sqlite_runtime = MycRuntime::bootstrap(sqlite_config.clone()).expect("sqlite runtime"); 146 assert_eq!( 147 sqlite_runtime 148 .signer_manager() 149 .expect("sqlite manager") 150 .list_connections() 151 .expect("sqlite connections") 152 .len(), 153 1 154 ); 155 assert_eq!( 156 sqlite_runtime 157 .operation_audit_store() 158 .list_all() 159 .expect("sqlite audit records") 160 .len(), 161 1 162 ); 163 164 let rerun = Command::new(env!("CARGO_BIN_EXE_myc")) 165 .arg("--env-file") 166 .arg(&env_path) 167 .arg("persistence") 168 .arg("import-json-to-sqlite") 169 .output() 170 .expect("rerun import"); 171 172 assert!(!rerun.status.success(), "{:?}", rerun); 173 let stderr = String::from_utf8(rerun.stderr).expect("rerun stderr"); 174 assert!(stderr.contains("sqlite signer-state destination")); 175 } 176 177 #[test] 178 fn persistence_backup_cli_copies_sqlite_state_and_identity_files() { 179 let source = tempfile::tempdir().expect("source tempdir"); 180 let sqlite_config = migrate_to_sqlite(&source); 181 let env_path = source.path().join("sqlite.env"); 182 write_env(&env_path, &sqlite_config); 183 let backup_dir = source.path().join("backup"); 184 185 let output = run_myc(&env_path, &["persistence", "backup", "--out"]); 186 assert!( 187 !output.status.success(), 188 "missing backup path should fail clap parsing" 189 ); 190 191 let output = run_myc( 192 &env_path, 193 &[ 194 "persistence", 195 "backup", 196 "--out", 197 backup_dir.to_str().expect("backup dir str"), 198 ], 199 ); 200 assert!(output.status.success(), "{:?}", output); 201 202 let parsed: Value = serde_json::from_slice(&output.stdout).expect("backup json"); 203 assert_eq!(parsed["signer_identity_reference"]["copied_file_count"], 2); 204 assert_eq!(parsed["user_identity_reference"]["copied_file_count"], 2); 205 assert_eq!( 206 parsed["discovery_app_identity_reference"], 207 Value::Null, 208 "default config reuses signer identity and should not emit a dedicated discovery backup" 209 ); 210 assert!(backup_dir.join("manifest.json").is_file()); 211 assert!( 212 backup_dir 213 .join("state") 214 .join("signer-state.sqlite") 215 .is_file() 216 ); 217 assert!( 218 backup_dir 219 .join("state") 220 .join("delivery-outbox.sqlite") 221 .is_file() 222 ); 223 assert!( 224 backup_dir 225 .join("state") 226 .join("audit") 227 .join("operations.sqlite") 228 .is_file() 229 ); 230 assert!( 231 backup_dir 232 .join("identity-references") 233 .join("signer") 234 .join("path") 235 .is_file() 236 ); 237 assert!( 238 backup_dir 239 .join("identity-references") 240 .join("signer") 241 .join("encrypted-key-path") 242 .is_file() 243 ); 244 assert!( 245 backup_dir 246 .join("identity-references") 247 .join("user") 248 .join("path") 249 .is_file() 250 ); 251 assert!( 252 backup_dir 253 .join("identity-references") 254 .join("user") 255 .join("encrypted-key-path") 256 .is_file() 257 ); 258 } 259 260 #[test] 261 fn persistence_backup_cli_rejects_destination_inside_state_dir() { 262 let source = tempfile::tempdir().expect("source tempdir"); 263 let sqlite_config = migrate_to_sqlite(&source); 264 let env_path = source.path().join("sqlite.env"); 265 write_env(&env_path, &sqlite_config); 266 let nested_backup_dir = sqlite_config.paths.state_dir.join("backup"); 267 268 let output = run_myc( 269 &env_path, 270 &[ 271 "persistence", 272 "backup", 273 "--out", 274 nested_backup_dir.to_str().expect("nested backup dir str"), 275 ], 276 ); 277 278 assert!(!output.status.success(), "{:?}", output); 279 let stderr = String::from_utf8(output.stderr).expect("backup stderr"); 280 assert!(stderr.contains("cannot copy")); 281 } 282 283 #[test] 284 fn persistence_restore_cli_restores_backup_and_verify_restore_passes() { 285 let source = tempfile::tempdir().expect("source tempdir"); 286 let sqlite_config = migrate_to_sqlite(&source); 287 let sqlite_env = source.path().join("sqlite.env"); 288 write_env(&sqlite_env, &sqlite_config); 289 let backup_dir = source.path().join("backup"); 290 let backup = run_myc( 291 &sqlite_env, 292 &[ 293 "persistence", 294 "backup", 295 "--out", 296 backup_dir.to_str().expect("backup dir str"), 297 ], 298 ); 299 assert!(backup.status.success(), "{:?}", backup); 300 301 let restored = tempfile::tempdir().expect("restored tempdir"); 302 let restored_signer = restored.path().join("signer.json"); 303 let restored_user = restored.path().join("user.json"); 304 305 let mut restored_config = sqlite_config.clone(); 306 restored_config.paths.state_dir = restored.path().join("state"); 307 restored_config.paths.signer_identity_path = restored_signer; 308 restored_config.paths.user_identity_path = restored_user; 309 let restored_env = restored.path().join("restored.env"); 310 write_env(&restored_env, &restored_config); 311 312 let restore = run_myc( 313 &restored_env, 314 &[ 315 "persistence", 316 "restore", 317 "--from", 318 backup_dir.to_str().expect("backup dir str"), 319 ], 320 ); 321 assert!(restore.status.success(), "{:?}", restore); 322 323 let restore_json: Value = serde_json::from_slice(&restore.stdout).expect("restore json"); 324 assert_eq!( 325 restore_json["signer_identity_reference"]["restored_file_count"], 326 2 327 ); 328 assert_eq!( 329 restore_json["user_identity_reference"]["restored_file_count"], 330 2 331 ); 332 assert!( 333 restored_config 334 .paths 335 .state_dir 336 .join("signer-state.sqlite") 337 .is_file() 338 ); 339 assert!(restored_config.paths.signer_identity_path.is_file()); 340 assert!(restored_config.paths.user_identity_path.is_file()); 341 assert!( 342 myc::identity_files::encrypted_identity_wrapping_key_path( 343 &restored_config.paths.signer_identity_path 344 ) 345 .is_file() 346 ); 347 assert!( 348 myc::identity_files::encrypted_identity_wrapping_key_path( 349 &restored_config.paths.user_identity_path 350 ) 351 .is_file() 352 ); 353 354 let output = run_myc(&restored_env, &["persistence", "verify-restore"]); 355 356 assert!(output.status.success(), "{:?}", output); 357 358 let parsed: Value = serde_json::from_slice(&output.stdout).expect("verify restore json"); 359 assert_eq!(parsed["signer_state"]["backend"], "sqlite"); 360 assert_eq!(parsed["signer_state"]["connection_count"], 1); 361 assert_eq!(parsed["runtime_audit"]["backend"], "sqlite"); 362 assert_eq!(parsed["runtime_audit"]["record_count"], 1); 363 assert_eq!(parsed["delivery_outbox"]["queued_job_count"], 0); 364 assert_eq!(parsed["delivery_outbox"]["unfinished_job_count"], 0); 365 assert!( 366 parsed["delivery_outbox"]["path"] 367 .as_str() 368 .expect("delivery outbox path") 369 .ends_with("delivery-outbox.sqlite") 370 ); 371 } 372 373 #[test] 374 fn persistence_verify_restore_cli_rejects_missing_outbox_file() { 375 let source = tempfile::tempdir().expect("source tempdir"); 376 let sqlite_config = migrate_to_sqlite(&source); 377 378 let restored = tempfile::tempdir().expect("restored tempdir"); 379 let restored_state_dir = restored.path().join("state"); 380 copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir); 381 let restored_signer = restored.path().join("signer.json"); 382 let restored_user = restored.path().join("user.json"); 383 std::fs::copy(&sqlite_config.paths.signer_identity_path, &restored_signer) 384 .expect("copy signer identity"); 385 std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user) 386 .expect("copy user identity"); 387 std::fs::remove_file(restored_state_dir.join("delivery-outbox.sqlite")) 388 .expect("remove restored outbox"); 389 390 let mut restored_config = sqlite_config.clone(); 391 restored_config.paths.state_dir = restored_state_dir; 392 restored_config.paths.signer_identity_path = restored_signer; 393 restored_config.paths.user_identity_path = restored_user; 394 let restored_env = restored.path().join("restored.env"); 395 std::fs::write( 396 &restored_env, 397 restored_config 398 .to_env_string() 399 .expect("render restored env"), 400 ) 401 .expect("write restored env"); 402 403 let output = Command::new(env!("CARGO_BIN_EXE_myc")) 404 .arg("--env-file") 405 .arg(&restored_env) 406 .arg("persistence") 407 .arg("verify-restore") 408 .output() 409 .expect("run verify restore"); 410 411 assert!(!output.status.success(), "{:?}", output); 412 let stderr = String::from_utf8(output.stderr).expect("verify restore stderr"); 413 assert!( 414 stderr.contains("persistence verify-restore requires an existing delivery outbox file") 415 ); 416 } 417 418 #[test] 419 fn persistence_restore_cli_rejects_non_empty_destination() { 420 let source = tempfile::tempdir().expect("source tempdir"); 421 let sqlite_config = migrate_to_sqlite(&source); 422 let sqlite_env = source.path().join("sqlite.env"); 423 write_env(&sqlite_env, &sqlite_config); 424 let backup_dir = source.path().join("backup"); 425 let backup = run_myc( 426 &sqlite_env, 427 &[ 428 "persistence", 429 "backup", 430 "--out", 431 backup_dir.to_str().expect("backup dir str"), 432 ], 433 ); 434 assert!(backup.status.success(), "{:?}", backup); 435 436 let restored = tempfile::tempdir().expect("restored tempdir"); 437 let mut restored_config = sqlite_config.clone(); 438 restored_config.paths.state_dir = restored.path().join("state"); 439 restored_config.paths.signer_identity_path = restored.path().join("signer.json"); 440 restored_config.paths.user_identity_path = restored.path().join("user.json"); 441 std::fs::create_dir_all(&restored_config.paths.state_dir).expect("create restored state dir"); 442 std::fs::write( 443 restored_config.paths.state_dir.join("existing.txt"), 444 "occupied", 445 ) 446 .expect("write occupied marker"); 447 let restored_env = restored.path().join("restored.env"); 448 write_env(&restored_env, &restored_config); 449 450 let restore = run_myc( 451 &restored_env, 452 &[ 453 "persistence", 454 "restore", 455 "--from", 456 backup_dir.to_str().expect("backup dir str"), 457 ], 458 ); 459 460 assert!(!restore.status.success(), "{:?}", restore); 461 let stderr = String::from_utf8(restore.stderr).expect("restore stderr"); 462 assert!(stderr.contains("restore state directory")); 463 } 464 465 #[test] 466 fn persistence_verify_restore_cli_rejects_signer_identity_mismatch() { 467 let source = tempfile::tempdir().expect("source tempdir"); 468 let sqlite_config = migrate_to_sqlite(&source); 469 470 let restored = tempfile::tempdir().expect("restored tempdir"); 471 let restored_state_dir = restored.path().join("state"); 472 copy_dir_recursive(&sqlite_config.paths.state_dir, &restored_state_dir); 473 let restored_signer = restored.path().join("other-signer.json"); 474 let restored_user = restored.path().join("user.json"); 475 write_identity( 476 &restored_signer, 477 "3333333333333333333333333333333333333333333333333333333333333333", 478 ); 479 std::fs::copy(&sqlite_config.paths.user_identity_path, &restored_user) 480 .expect("copy user identity"); 481 std::fs::copy( 482 myc::identity_files::encrypted_identity_wrapping_key_path( 483 &sqlite_config.paths.user_identity_path, 484 ), 485 myc::identity_files::encrypted_identity_wrapping_key_path(&restored_user), 486 ) 487 .expect("copy user identity wrapping key"); 488 489 let mut restored_config = sqlite_config.clone(); 490 restored_config.paths.state_dir = restored_state_dir; 491 restored_config.paths.signer_identity_path = restored_signer; 492 restored_config.paths.user_identity_path = restored_user; 493 let restored_env = restored.path().join("restored.env"); 494 std::fs::write( 495 &restored_env, 496 restored_config 497 .to_env_string() 498 .expect("render restored env"), 499 ) 500 .expect("write restored env"); 501 502 let output = Command::new(env!("CARGO_BIN_EXE_myc")) 503 .arg("--env-file") 504 .arg(&restored_env) 505 .arg("persistence") 506 .arg("verify-restore") 507 .output() 508 .expect("run verify restore"); 509 510 assert!(!output.status.success(), "{:?}", output); 511 let stderr = String::from_utf8(output.stderr).expect("verify restore stderr"); 512 assert!(stderr.contains("does not match persisted signer identity")); 513 }