operability_cli.rs (14159B)
1 use std::fs; 2 use std::path::Path; 3 use std::process::Command; 4 5 use myc::{ 6 MYC_SIGNER_STATUS_CONTRACT_VERSION, MycActiveIdentity, MycDeliveryOutboxKind, 7 MycDeliveryOutboxRecord, MycOperationAuditKind, MycOperationAuditOutcome, 8 MycOperationAuditRecord, MycRuntime, 9 }; 10 use radroots_identity::RadrootsIdentity; 11 use radroots_nostr::prelude::{RadrootsNostrEventBuilder, RadrootsNostrKind}; 12 use serde_json::{Value, json}; 13 14 fn write_test_identity(path: &Path, secret_key: &str) { 15 let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity from secret"); 16 myc::identity_files::store_encrypted_identity(path, &identity).expect("write identity"); 17 } 18 19 fn write_env_file(temp: &tempfile::TempDir) -> std::path::PathBuf { 20 let state_dir = temp.path().join("state"); 21 let signer_path = temp.path().join("signer.json"); 22 let user_path = temp.path().join("user.json"); 23 let env_path = temp.path().join("myc.env"); 24 25 write_test_identity( 26 signer_path.as_path(), 27 "1111111111111111111111111111111111111111111111111111111111111111", 28 ); 29 write_test_identity( 30 user_path.as_path(), 31 "2222222222222222222222222222222222222222222222222222222222222222", 32 ); 33 34 std::fs::write( 35 &env_path, 36 format!( 37 "MYC_SERVICE_INSTANCE_NAME=myc-test\n\ 38 MYC_LOGGING_FILTER=info,myc=info\n\ 39 MYC_LOGGING_STDOUT=false\n\ 40 MYC_PATHS_STATE_DIR={}\n\ 41 MYC_IDENTITY_SIGNER_PATH={}\n\ 42 MYC_IDENTITY_USER_PATH={}\n\ 43 MYC_DISCOVERY_ENABLED=false\n\ 44 MYC_TRANSPORT_ENABLED=false\n\ 45 MYC_TRANSPORT_CONNECT_TIMEOUT_SECS=1\n", 46 state_dir.display(), 47 signer_path.display(), 48 user_path.display(), 49 ), 50 ) 51 .expect("write env"); 52 53 env_path 54 } 55 56 fn signed_event(identity: &MycActiveIdentity) -> nostr::Event { 57 identity 58 .sign_event_builder( 59 RadrootsNostrEventBuilder::new(RadrootsNostrKind::Custom(24133), "operability"), 60 "operability test event", 61 ) 62 .expect("sign event") 63 } 64 65 #[test] 66 fn status_signer_command_emits_local_contract_json() { 67 let temp = tempfile::tempdir().expect("tempdir"); 68 let env_path = write_env_file(&temp); 69 70 let output = Command::new(env!("CARGO_BIN_EXE_myc")) 71 .arg("--env-file") 72 .arg(&env_path) 73 .arg("status") 74 .arg("--view") 75 .arg("signer") 76 .output() 77 .expect("run myc signer status"); 78 79 assert!(output.status.success()); 80 let value: Value = serde_json::from_slice(&output.stdout).expect("signer status json"); 81 assert_eq!( 82 value["status_contract_version"], 83 MYC_SIGNER_STATUS_CONTRACT_VERSION 84 ); 85 assert_eq!(value["status"], "healthy"); 86 assert_eq!(value["ready"], true); 87 assert_eq!( 88 value["runtime_contract"]["active_profile"], 89 "interactive_user" 90 ); 91 assert_eq!(value["custody"]["signer"]["resolved"], true); 92 assert_eq!(value["custody"]["user"]["resolved"], true); 93 assert_eq!( 94 value["signer_backend"]["local_signer"]["availability"], 95 "SecretBacked" 96 ); 97 assert_eq!(value["signer_backend"]["remote_session_count"], 0); 98 assert!(value.get("transport").is_none()); 99 assert!(value.get("discovery").is_none()); 100 assert!(value.get("persistence").is_none()); 101 assert!(value.get("delivery_outbox").is_none()); 102 } 103 104 #[test] 105 fn status_ignores_retired_process_env_config_names() { 106 let temp = tempfile::tempdir().expect("tempdir"); 107 let env_path = write_env_file(&temp); 108 109 let output = Command::new(env!("CARGO_BIN_EXE_myc")) 110 .env( 111 "MYC_PATHS_SIGNER_IDENTITY_PATH", 112 temp.path().join("missing-signer.json"), 113 ) 114 .env( 115 "MYC_PATHS_USER_IDENTITY_PATH", 116 temp.path().join("missing-user.json"), 117 ) 118 .env("MYC_DISCOVERY_PUBLIC_RELAYS", "not-a-relay") 119 .env("MYC_TRANSPORT_RELAYS", "not-a-relay") 120 .env("MYC_TRANSPORT_PUBLISH_INITIAL_BACKOFF_MILLIS", "0") 121 .arg("--env-file") 122 .arg(&env_path) 123 .arg("status") 124 .arg("--view") 125 .arg("signer") 126 .output() 127 .expect("run myc signer status"); 128 129 assert!(output.status.success()); 130 let value: Value = serde_json::from_slice(&output.stdout).expect("signer status json"); 131 assert_eq!(value["ready"], true); 132 assert_eq!(value["custody"]["signer"]["resolved"], true); 133 assert_eq!(value["custody"]["user"]["resolved"], true); 134 } 135 136 #[test] 137 fn status_summary_command_emits_machine_readable_json() { 138 let temp = tempfile::tempdir().expect("tempdir"); 139 let env_path = write_env_file(&temp); 140 141 let output = Command::new(env!("CARGO_BIN_EXE_myc")) 142 .arg("--env-file") 143 .arg(&env_path) 144 .arg("status") 145 .arg("--view") 146 .arg("summary") 147 .output() 148 .expect("run myc status"); 149 150 assert!(output.status.success()); 151 let value: Value = serde_json::from_slice(&output.stdout).expect("status json"); 152 assert_eq!(value["status"], "unready"); 153 assert_eq!(value["ready"], false); 154 assert_eq!( 155 value["runtime_contract"]["active_profile"], 156 "interactive_user" 157 ); 158 assert_eq!( 159 value["runtime_contract"]["path_overrides"]["canonical_root_selection"], 160 "profile_root_env_or_repo_wrapper" 161 ); 162 assert_eq!( 163 value["runtime_contract"]["path_overrides"]["canonical_subordinate_path_override"], 164 "config_artifact" 165 ); 166 assert_eq!( 167 value["runtime_contract"]["path_overrides"]["leaf_path_env_posture"], 168 "compatibility_break_glass" 169 ); 170 assert_eq!( 171 value["runtime_contract"]["path_overrides"]["compatibility_leaf_path_keys"], 172 json!([ 173 "MYC_LOGGING_OUTPUT_DIR", 174 "MYC_PATHS_STATE_DIR", 175 "MYC_IDENTITY_SIGNER_PATH", 176 "MYC_IDENTITY_USER_PATH", 177 "MYC_IDENTITY_DISCOVERY_APP_PATH", 178 "MYC_DISCOVERY_NIP05_OUTPUT_PATH" 179 ]) 180 ); 181 assert_eq!( 182 value["runtime_contract"]["allowed_profiles"], 183 json!(["interactive_user", "service_host", "repo_local"]) 184 ); 185 assert_eq!( 186 value["runtime_contract"]["default_shared_secret_backend"], 187 "encrypted_file" 188 ); 189 assert_eq!( 190 value["runtime_contract"]["allowed_shared_secret_backends"], 191 json!([ 192 "encrypted_file", 193 "host_vault", 194 "external_command", 195 "plaintext_file" 196 ]) 197 ); 198 assert_eq!( 199 value["runtime_contract"]["runtime_specific_custody_modes"], 200 json!(["managed_account"]) 201 ); 202 assert_eq!(value["runtime_contract"]["host_vault_policy"], "desktop"); 203 assert_eq!(value["custody"]["signer"]["backend"], "encrypted_file"); 204 assert_eq!(value["custody"]["signer"]["resolved"], true); 205 assert_eq!(value["persistence"]["signer_state"]["backend"], "json_file"); 206 assert_eq!( 207 value["persistence"]["runtime_audit"]["backend"], 208 "jsonl_file" 209 ); 210 assert_eq!(value["delivery_outbox"]["status"], "healthy"); 211 assert_eq!(value["delivery_outbox"]["ready"], true); 212 assert_eq!(value["delivery_outbox"]["total_job_count"], 0); 213 assert_eq!(value["transport"]["enabled"], false); 214 } 215 216 #[test] 217 fn metrics_command_emits_json_and_prometheus_formats() { 218 let temp = tempfile::tempdir().expect("tempdir"); 219 let env_path = write_env_file(&temp); 220 let config = myc::MycConfig::load_from_env_path(&env_path).expect("load config"); 221 let runtime = MycRuntime::bootstrap(config).expect("runtime"); 222 runtime.record_operation_audit(&MycOperationAuditRecord::new( 223 MycOperationAuditKind::AuthReplayRestore, 224 MycOperationAuditOutcome::Restored, 225 None, 226 None, 227 1, 228 0, 229 "restored pending request after failed replay publish", 230 )); 231 runtime.record_operation_audit(&MycOperationAuditRecord::new( 232 MycOperationAuditKind::DeliveryRecovery, 233 MycOperationAuditOutcome::Succeeded, 234 None, 235 None, 236 1, 237 1, 238 "recovered 1/1 delivery outbox job(s); republished 1", 239 )); 240 let outbox_record = MycDeliveryOutboxRecord::new( 241 MycDeliveryOutboxKind::DiscoveryHandlerPublish, 242 signed_event(runtime.signer_identity()), 243 vec!["wss://relay.example.com".parse().expect("relay url")], 244 ) 245 .expect("outbox record"); 246 runtime 247 .delivery_outbox_store() 248 .enqueue(&outbox_record) 249 .expect("enqueue outbox record"); 250 251 let json_output = Command::new(env!("CARGO_BIN_EXE_myc")) 252 .arg("--env-file") 253 .arg(&env_path) 254 .arg("metrics") 255 .arg("--format") 256 .arg("json") 257 .output() 258 .expect("run myc metrics json"); 259 assert!(json_output.status.success()); 260 let json_value: Value = serde_json::from_slice(&json_output.stdout).expect("metrics json"); 261 assert_eq!(json_value["runtime_replay_restore_count"], 1); 262 assert_eq!(json_value["delivery_recovery_success_count"], 1); 263 assert_eq!(json_value["delivery_outbox_total"], 1); 264 assert_eq!(json_value["delivery_outbox_queued_count"], 1); 265 266 let prometheus_output = Command::new(env!("CARGO_BIN_EXE_myc")) 267 .arg("--env-file") 268 .arg(&env_path) 269 .arg("metrics") 270 .arg("--format") 271 .arg("prometheus") 272 .output() 273 .expect("run myc metrics prometheus"); 274 assert!(prometheus_output.status.success()); 275 let rendered = String::from_utf8(prometheus_output.stdout).expect("utf8 metrics"); 276 assert!(rendered.contains("myc_runtime_replay_restore_total 1")); 277 assert!(rendered.contains("myc_delivery_recovery_success_total 1")); 278 assert!(rendered.contains("myc_delivery_outbox_total 1")); 279 assert!(rendered.contains("myc_signer_request_total 0")); 280 } 281 282 #[test] 283 fn custody_status_command_reports_role_backend_details() { 284 let temp = tempfile::tempdir().expect("tempdir"); 285 let env_path = write_env_file(&temp); 286 287 let output = Command::new(env!("CARGO_BIN_EXE_myc")) 288 .arg("--env-file") 289 .arg(&env_path) 290 .arg("custody") 291 .arg("status") 292 .arg("--role") 293 .arg("signer") 294 .output() 295 .expect("run myc custody status"); 296 297 assert!(output.status.success()); 298 let value: Value = serde_json::from_slice(&output.stdout).expect("custody status json"); 299 assert_eq!(value["backend"], "encrypted_file"); 300 assert_eq!(value["resolved"], true); 301 assert_eq!(value["default_shared_secret_backend"], "encrypted_file"); 302 assert_eq!( 303 value["allowed_shared_secret_backends"], 304 json!([ 305 "encrypted_file", 306 "host_vault", 307 "external_command", 308 "plaintext_file" 309 ]) 310 ); 311 assert_eq!( 312 value["runtime_specific_custody_modes"], 313 json!(["managed_account"]) 314 ); 315 assert_eq!(value["host_vault_policy"], "desktop"); 316 assert_eq!( 317 value["identity_id"], 318 "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" 319 ); 320 } 321 322 #[test] 323 fn custody_export_import_and_rotate_nip49_for_encrypted_file_backend() { 324 let temp = tempfile::tempdir().expect("tempdir"); 325 let env_path = write_env_file(&temp); 326 let signer_path = temp.path().join("signer.json"); 327 let export_path = temp.path().join("signer.ncryptsec"); 328 let key_path = myc::identity_files::encrypted_identity_wrapping_key_path(&signer_path); 329 330 let export_output = Command::new(env!("CARGO_BIN_EXE_myc")) 331 .env("MYC_TEST_PASSWORD", "correct horse battery staple") 332 .arg("--env-file") 333 .arg(&env_path) 334 .arg("custody") 335 .arg("export-nip49") 336 .arg("--role") 337 .arg("signer") 338 .arg("--out") 339 .arg(&export_path) 340 .arg("--password-env") 341 .arg("MYC_TEST_PASSWORD") 342 .output() 343 .expect("run myc custody export-nip49"); 344 345 assert!(export_output.status.success()); 346 let export_value: Value = 347 serde_json::from_slice(&export_output.stdout).expect("export-nip49 json"); 348 assert_eq!(export_value["format"], "nip49"); 349 assert_eq!(export_value["out"], export_path.display().to_string()); 350 let exported = fs::read_to_string(&export_path).expect("read exported ncryptsec"); 351 assert!(exported.starts_with("ncryptsec1")); 352 353 fs::remove_file(&signer_path).expect("remove signer identity"); 354 fs::remove_file(&key_path).expect("remove signer wrapping key"); 355 356 let import_output = Command::new(env!("CARGO_BIN_EXE_myc")) 357 .env("MYC_TEST_PASSWORD", "correct horse battery staple") 358 .arg("--env-file") 359 .arg(&env_path) 360 .arg("custody") 361 .arg("import-nip49") 362 .arg("--role") 363 .arg("signer") 364 .arg("--path") 365 .arg(&export_path) 366 .arg("--password-env") 367 .arg("MYC_TEST_PASSWORD") 368 .output() 369 .expect("run myc custody import-nip49"); 370 371 assert!(import_output.status.success()); 372 let import_value: Value = 373 serde_json::from_slice(&import_output.stdout).expect("import-nip49 json"); 374 assert_eq!(import_value["format"], "nip49"); 375 assert_eq!(import_value["status"]["resolved"], true); 376 let restored = myc::identity_files::load_encrypted_identity(&signer_path) 377 .expect("load restored encrypted identity"); 378 assert_eq!( 379 restored.id().to_string(), 380 "4f355bdcb7cc0af728ef3cceb9615d90684bb5b2ca5f859ab0f0b704075871aa" 381 ); 382 383 let key_before_rotation = fs::read(&key_path).expect("read key before rotation"); 384 let rotate_output = Command::new(env!("CARGO_BIN_EXE_myc")) 385 .arg("--env-file") 386 .arg(&env_path) 387 .arg("custody") 388 .arg("rotate") 389 .arg("--role") 390 .arg("signer") 391 .output() 392 .expect("run myc custody rotate"); 393 394 assert!(rotate_output.status.success()); 395 let rotate_value: Value = serde_json::from_slice(&rotate_output.stdout).expect("rotate json"); 396 assert_eq!(rotate_value["action"], "rotate"); 397 assert_eq!(rotate_value["status"]["resolved"], true); 398 let key_after_rotation = fs::read(&key_path).expect("read key after rotation"); 399 assert_ne!(key_before_rotation, key_after_rotation); 400 }