cli.rs (54110B)
1 use std::collections::BTreeMap; 2 use std::path::{Path, PathBuf}; 3 use std::time::Duration; 4 5 use clap::{Args, Parser, Subcommand, ValueEnum}; 6 use radroots_nostr_connect::prelude::RadrootsNostrConnectPermissions; 7 use radroots_nostr_signer::prelude::{ 8 RadrootsNostrSignerBackend, RadrootsNostrSignerConnectionId, 9 RadrootsNostrSignerConnectionRecord, RadrootsNostrSignerRequestAuditRecord, 10 }; 11 use serde::Serialize; 12 use zeroize::Zeroizing; 13 14 use crate::app::MycRuntime; 15 use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; 16 use crate::config::{MycConfig, MycTransportDeliveryPolicy}; 17 use crate::control::{accept_client_uri, authorize_auth_challenge, parse_permission_values}; 18 use crate::discovery::{ 19 MycDiscoveryContext, MycDiscoveryRepairSummary, diff_live_nip89, fetch_live_nip89, 20 publish_nip89_event, refresh_nip89, verify_bundle, 21 }; 22 use crate::error::MycError; 23 use crate::logging; 24 use crate::operability::{ 25 MycAuditDecisionCounts, MycOperationOutcomeCounts, MycStatusFullOutput, MycStatusSignerOutput, 26 MycStatusSummaryOutput, collect_metrics, collect_status_full, collect_status_signer, 27 collect_status_summary, increment_outcome_counts, is_aggregate_publish_operation, 28 operation_kind_label, render_metrics_text, 29 }; 30 use crate::persistence::{ 31 MycPersistenceImportSelection, backup_persistence, import_json_to_sqlite, restore_backup, 32 verify_restored_state, 33 }; 34 35 #[derive(Debug, Parser)] 36 #[command(name = "myc")] 37 #[command(about = "Mycorrhiza NIP-46 signer service")] 38 pub struct MycCli { 39 #[arg(long = "env-file", global = true)] 40 env_file: Option<PathBuf>, 41 #[command(subcommand)] 42 command: Option<MycCommand>, 43 } 44 45 #[derive(Debug, Subcommand)] 46 pub enum MycCommand { 47 Run, 48 Status { 49 #[arg(long, value_enum, default_value_t = MycStatusView::Summary)] 50 view: MycStatusView, 51 }, 52 Metrics { 53 #[arg(long, value_enum, default_value_t = MycMetricsFormat::Prometheus)] 54 format: MycMetricsFormat, 55 }, 56 Persistence { 57 #[command(subcommand)] 58 command: MycPersistenceCommand, 59 }, 60 Custody { 61 #[command(subcommand)] 62 command: MycCustodyCommand, 63 }, 64 Connections { 65 #[command(subcommand)] 66 command: MycConnectionsCommand, 67 }, 68 Audit { 69 #[command(subcommand)] 70 command: MycAuditCommand, 71 }, 72 Auth { 73 #[command(subcommand)] 74 command: MycAuthCommand, 75 }, 76 Connect { 77 #[command(subcommand)] 78 command: MycConnectCommand, 79 }, 80 Discovery { 81 #[command(subcommand)] 82 command: MycDiscoveryCommand, 83 }, 84 } 85 86 #[derive(Debug, Subcommand)] 87 pub enum MycConnectionsCommand { 88 List, 89 Approve(MycConnectionApprovalArgs), 90 Reject(MycConnectionReasonArgs), 91 Revoke(MycConnectionReasonArgs), 92 } 93 94 #[derive(Debug, Subcommand)] 95 pub enum MycPersistenceCommand { 96 Backup { 97 #[arg(long)] 98 out: PathBuf, 99 }, 100 Restore { 101 #[arg(long)] 102 from: PathBuf, 103 }, 104 ImportJsonToSqlite { 105 #[arg(long)] 106 signer_state: bool, 107 #[arg(long)] 108 runtime_audit: bool, 109 }, 110 VerifyRestore, 111 } 112 113 #[derive(Debug, Subcommand)] 114 pub enum MycCustodyCommand { 115 Status { 116 #[arg(long, value_enum)] 117 role: MycCustodyRole, 118 }, 119 List { 120 #[arg(long, value_enum)] 121 role: MycCustodyRole, 122 }, 123 Generate { 124 #[arg(long, value_enum)] 125 role: MycCustodyRole, 126 #[arg(long)] 127 label: Option<String>, 128 #[arg(long)] 129 select: bool, 130 }, 131 ImportFile { 132 #[arg(long, value_enum)] 133 role: MycCustodyRole, 134 #[arg(long)] 135 path: PathBuf, 136 #[arg(long)] 137 label: Option<String>, 138 #[arg(long)] 139 select: bool, 140 }, 141 ExportNip49 { 142 #[arg(long, value_enum)] 143 role: MycCustodyRole, 144 #[arg(long)] 145 out: PathBuf, 146 #[arg(long)] 147 password_env: String, 148 }, 149 ImportNip49 { 150 #[arg(long, value_enum)] 151 role: MycCustodyRole, 152 #[arg(long)] 153 path: PathBuf, 154 #[arg(long)] 155 password_env: String, 156 #[arg(long)] 157 label: Option<String>, 158 }, 159 Rotate { 160 #[arg(long, value_enum)] 161 role: MycCustodyRole, 162 }, 163 Select { 164 #[arg(long, value_enum)] 165 role: MycCustodyRole, 166 #[arg(long)] 167 account_id: String, 168 }, 169 Remove { 170 #[arg(long, value_enum)] 171 role: MycCustodyRole, 172 #[arg(long)] 173 account_id: String, 174 }, 175 } 176 177 #[derive(Debug, Subcommand)] 178 pub enum MycAuditCommand { 179 List { 180 #[arg(long)] 181 connection_id: Option<String>, 182 #[arg(long)] 183 attempt_id: Option<String>, 184 #[arg(long, value_enum, default_value_t = MycAuditScope::All)] 185 scope: MycAuditScope, 186 #[arg(long)] 187 limit: Option<usize>, 188 }, 189 Summary { 190 #[arg(long)] 191 connection_id: Option<String>, 192 #[arg(long)] 193 attempt_id: Option<String>, 194 #[arg(long, value_enum, default_value_t = MycAuditScope::All)] 195 scope: MycAuditScope, 196 #[arg(long)] 197 limit: Option<usize>, 198 }, 199 LatestDiscoveryRepair { 200 #[arg(long, value_enum, default_value_t = MycDiscoveryRepairAttemptView::Summary)] 201 view: MycDiscoveryRepairAttemptView, 202 }, 203 DiscoveryRepairAttempt { 204 #[arg(long)] 205 attempt_id: String, 206 #[arg(long, value_enum, default_value_t = MycDiscoveryRepairAttemptView::Summary)] 207 view: MycDiscoveryRepairAttemptView, 208 }, 209 } 210 211 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] 212 pub enum MycAuditScope { 213 All, 214 Request, 215 Operation, 216 } 217 218 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] 219 pub enum MycDiscoveryRepairAttemptView { 220 Summary, 221 Records, 222 } 223 224 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] 225 pub enum MycStatusView { 226 Signer, 227 Summary, 228 Full, 229 } 230 231 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] 232 pub enum MycMetricsFormat { 233 Json, 234 Prometheus, 235 } 236 237 #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] 238 pub enum MycCustodyRole { 239 Signer, 240 User, 241 DiscoveryApp, 242 } 243 244 #[derive(Debug, Subcommand)] 245 pub enum MycAuthCommand { 246 Require { 247 #[arg(long)] 248 connection_id: String, 249 #[arg(long)] 250 url: String, 251 }, 252 Authorize { 253 #[arg(long)] 254 connection_id: String, 255 }, 256 } 257 258 #[derive(Debug, Subcommand)] 259 pub enum MycConnectCommand { 260 Accept { 261 #[arg(long)] 262 uri: String, 263 }, 264 } 265 266 #[derive(Debug, Subcommand)] 267 pub enum MycDiscoveryCommand { 268 RenderNip05 { 269 #[arg(long)] 270 out: Option<PathBuf>, 271 #[arg(long)] 272 stdout: bool, 273 }, 274 RenderNip89, 275 PublishNip89, 276 ExportBundle { 277 #[arg(long)] 278 out: PathBuf, 279 }, 280 VerifyBundle { 281 #[arg(long)] 282 dir: PathBuf, 283 }, 284 InspectLiveNip89, 285 DiffLiveNip89, 286 RefreshNip89 { 287 #[arg(long)] 288 force: bool, 289 }, 290 } 291 292 #[derive(Debug, Args)] 293 pub struct MycConnectionApprovalArgs { 294 #[arg(long)] 295 connection_id: String, 296 #[arg(long = "grant")] 297 grants: Vec<String>, 298 } 299 300 #[derive(Debug, Args)] 301 pub struct MycConnectionReasonArgs { 302 #[arg(long)] 303 connection_id: String, 304 #[arg(long)] 305 reason: Option<String>, 306 } 307 308 #[derive(Debug, Serialize, PartialEq, Eq)] 309 pub struct MycAuditListOutput { 310 pub signer_request_audit: Vec<RadrootsNostrSignerRequestAuditRecord>, 311 pub runtime_operation_audit: Vec<MycOperationAuditRecord>, 312 } 313 314 #[derive(Debug, Serialize, PartialEq, Eq)] 315 pub struct MycAuditSummaryOutput { 316 pub record_limit: usize, 317 pub signer_request_total: usize, 318 pub signer_request_decisions: MycAuditDecisionCounts, 319 pub runtime_operation_total: usize, 320 pub runtime_operation_outcomes: MycOperationOutcomeCounts, 321 pub runtime_operation_by_kind: BTreeMap<String, MycOperationOutcomeCounts>, 322 pub runtime_aggregate_publish_rejection_count: usize, 323 pub runtime_repair_success_count: usize, 324 pub runtime_repair_rejection_count: usize, 325 pub runtime_unavailable_count: usize, 326 pub runtime_replay_restore_count: usize, 327 } 328 329 #[derive(Debug, Serialize, PartialEq, Eq)] 330 pub struct MycDiscoveryRepairAttemptRecordsOutput { 331 pub attempt_id: String, 332 pub runtime_operation_audit: Vec<MycOperationAuditRecord>, 333 } 334 335 #[derive(Debug, Serialize, PartialEq, Eq)] 336 pub struct MycDiscoveryRepairAttemptSummaryOutput { 337 pub attempt_id: String, 338 pub record_count: usize, 339 pub started_at_unix: u64, 340 pub finished_at_unix: u64, 341 #[serde(skip_serializing_if = "Option::is_none")] 342 pub compare_outcome: Option<MycOperationAuditOutcome>, 343 #[serde(skip_serializing_if = "Option::is_none")] 344 pub refresh_outcome: Option<MycOperationAuditOutcome>, 345 #[serde(skip_serializing_if = "Option::is_none")] 346 pub aggregate_publish_outcome: Option<MycOperationAuditOutcome>, 347 #[serde(skip_serializing_if = "Option::is_none")] 348 pub aggregate_publish_relay_count: Option<usize>, 349 #[serde(skip_serializing_if = "Option::is_none")] 350 pub aggregate_publish_acknowledged_relay_count: Option<usize>, 351 #[serde(skip_serializing_if = "Option::is_none")] 352 pub aggregate_publish_relay_outcome_summary: Option<String>, 353 #[serde(skip_serializing_if = "Option::is_none")] 354 pub aggregate_publish_delivery_policy: Option<MycTransportDeliveryPolicy>, 355 #[serde(skip_serializing_if = "Option::is_none")] 356 pub aggregate_publish_required_acknowledged_relay_count: Option<usize>, 357 #[serde(skip_serializing_if = "Option::is_none")] 358 pub aggregate_publish_attempt_count: Option<usize>, 359 pub repair_summary: MycDiscoveryRepairSummary, 360 pub planned_repair_relays: Vec<String>, 361 pub blocked_relays: Vec<String>, 362 #[serde(skip_serializing_if = "Option::is_none")] 363 pub blocked_reason: Option<String>, 364 pub failed_relays: Vec<String>, 365 pub remaining_repair_relays: Vec<String>, 366 } 367 368 #[derive(Debug, Serialize, PartialEq, Eq)] 369 #[serde(untagged)] 370 pub enum MycDiscoveryRepairAttemptOutput { 371 Summary(MycDiscoveryRepairAttemptSummaryOutput), 372 Records(MycDiscoveryRepairAttemptRecordsOutput), 373 } 374 375 #[derive(Debug, Serialize, PartialEq, Eq)] 376 #[serde(untagged)] 377 pub enum MycStatusOutput { 378 Signer(MycStatusSignerOutput), 379 Summary(MycStatusSummaryOutput), 380 Full(MycStatusFullOutput), 381 } 382 383 pub async fn run_from_env() -> Result<(), MycError> { 384 let cli = MycCli::parse(); 385 let config = load_config(cli.env_file.as_deref())?; 386 387 match cli.command.unwrap_or(MycCommand::Run) { 388 MycCommand::Run => { 389 logging::init_logging(&config.logging)?; 390 MycRuntime::bootstrap(config)?.run().await 391 } 392 MycCommand::Status { view } => { 393 let runtime = MycRuntime::bootstrap(config)?; 394 let output = match view { 395 MycStatusView::Signer => MycStatusOutput::Signer(collect_status_signer(&runtime)?), 396 MycStatusView::Summary => { 397 MycStatusOutput::Summary(collect_status_summary(&runtime).await?) 398 } 399 MycStatusView::Full => MycStatusOutput::Full(collect_status_full(&runtime).await?), 400 }; 401 print_json(&output) 402 } 403 MycCommand::Metrics { format } => { 404 let runtime = MycRuntime::bootstrap(config)?; 405 let output = collect_metrics(&runtime)?; 406 match format { 407 MycMetricsFormat::Json => print_json(&output), 408 MycMetricsFormat::Prometheus => { 409 print_text(&render_metrics_text(&output)); 410 Ok(()) 411 } 412 } 413 } 414 MycCommand::Persistence { command } => match command { 415 MycPersistenceCommand::Backup { out } => { 416 let output = backup_persistence(&config, out)?; 417 print_json(&output) 418 } 419 MycPersistenceCommand::Restore { from } => { 420 let output = restore_backup(&config, from)?; 421 print_json(&output) 422 } 423 MycPersistenceCommand::ImportJsonToSqlite { 424 signer_state, 425 runtime_audit, 426 } => { 427 let output = import_json_to_sqlite( 428 &config, 429 MycPersistenceImportSelection::new(signer_state, runtime_audit), 430 )?; 431 print_json(&output) 432 } 433 MycPersistenceCommand::VerifyRestore => { 434 let output = verify_restored_state(&config)?; 435 print_json(&output) 436 } 437 }, 438 MycCommand::Custody { command } => { 439 let provider = custody_provider_for_command(&config, &command)?; 440 match command { 441 MycCustodyCommand::Status { .. } => print_json(&provider.status_output()), 442 MycCustodyCommand::List { .. } => print_json(&provider.list_managed_accounts()?), 443 MycCustodyCommand::Generate { label, select, .. } => { 444 let output = provider.generate_managed_account(label, select)?; 445 print_json(&output) 446 } 447 MycCustodyCommand::ImportFile { 448 path, 449 label, 450 select, 451 .. 452 } => { 453 let output = provider.import_managed_account_file(path, label, select)?; 454 print_json(&output) 455 } 456 MycCustodyCommand::ExportNip49 { 457 out, password_env, .. 458 } => { 459 let password = read_secret_env(password_env.as_str(), "custody export-nip49")?; 460 let output = provider.export_nip49(out, password.as_str())?; 461 print_json(&output) 462 } 463 MycCustodyCommand::ImportNip49 { 464 path, 465 password_env, 466 label, 467 .. 468 } => { 469 let password = read_secret_env(password_env.as_str(), "custody import-nip49")?; 470 let output = provider.import_nip49(path, password.as_str(), label)?; 471 print_json(&output) 472 } 473 MycCustodyCommand::Rotate { .. } => { 474 let output = provider.rotate_secret_storage()?; 475 print_json(&output) 476 } 477 MycCustodyCommand::Select { account_id, .. } => { 478 let output = provider.select_managed_account(account_id.as_str())?; 479 print_json(&output) 480 } 481 MycCustodyCommand::Remove { account_id, .. } => { 482 let output = provider.remove_managed_account(account_id.as_str())?; 483 print_json(&output) 484 } 485 } 486 } 487 MycCommand::Connections { command } => { 488 let runtime = MycRuntime::bootstrap(config)?; 489 let backend = runtime.signer_backend(); 490 match command { 491 MycConnectionsCommand::List => print_json(&backend.list_connections()?), 492 MycConnectionsCommand::Approve(args) => { 493 let connection_id = parse_connection_id(&args.connection_id)?; 494 let granted_permissions = granted_permissions_for_approval( 495 runtime.signer_context().policy(), 496 &backend.list_connections()?, 497 &connection_id, 498 &args.grants, 499 )?; 500 let connection = 501 backend.approve_connection(&connection_id, granted_permissions)?; 502 print_json(&connection) 503 } 504 MycConnectionsCommand::Reject(args) => { 505 let connection_id = parse_connection_id(&args.connection_id)?; 506 let connection = backend.reject_connection(&connection_id, args.reason)?; 507 print_json(&connection) 508 } 509 MycConnectionsCommand::Revoke(args) => { 510 let connection_id = parse_connection_id(&args.connection_id)?; 511 let connection = backend.revoke_connection(&connection_id, args.reason)?; 512 print_json(&connection) 513 } 514 } 515 } 516 MycCommand::Audit { command } => { 517 let runtime = MycRuntime::bootstrap(config)?; 518 let manager = runtime.signer_manager()?; 519 match command { 520 MycAuditCommand::List { 521 connection_id, 522 attempt_id, 523 scope, 524 limit, 525 } => { 526 let output = load_audit_output( 527 &runtime, 528 &manager, 529 connection_id.as_deref(), 530 attempt_id.as_deref(), 531 scope, 532 limit, 533 )?; 534 print_json(&output) 535 } 536 MycAuditCommand::Summary { 537 connection_id, 538 attempt_id, 539 scope, 540 limit, 541 } => { 542 let output = summarize_audit_output( 543 &runtime, 544 &manager, 545 connection_id.as_deref(), 546 attempt_id.as_deref(), 547 scope, 548 limit, 549 )?; 550 print_json(&output) 551 } 552 MycAuditCommand::LatestDiscoveryRepair { view } => { 553 let output = load_latest_discovery_repair_attempt_output(&runtime, view)?; 554 print_json(&output) 555 } 556 MycAuditCommand::DiscoveryRepairAttempt { attempt_id, view } => { 557 let output = 558 load_discovery_repair_attempt_output(&runtime, attempt_id.as_str(), view)?; 559 print_json(&output) 560 } 561 } 562 } 563 MycCommand::Auth { command } => { 564 let runtime = MycRuntime::bootstrap(config)?; 565 let backend = runtime.signer_backend(); 566 match command { 567 MycAuthCommand::Require { connection_id, url } => { 568 let connection_id = parse_connection_id(&connection_id)?; 569 let connection = backend.require_auth_challenge(&connection_id, &url)?; 570 print_json(&connection) 571 } 572 MycAuthCommand::Authorize { connection_id } => { 573 let connection_id = parse_connection_id(&connection_id)?; 574 let replayed = authorize_auth_challenge(&runtime, &connection_id).await?; 575 print_json(&replayed) 576 } 577 } 578 } 579 MycCommand::Connect { command } => { 580 let runtime = MycRuntime::bootstrap(config)?; 581 match command { 582 MycConnectCommand::Accept { uri } => { 583 let accepted = accept_client_uri(&runtime, &uri).await?; 584 print_json(&accepted) 585 } 586 } 587 } 588 MycCommand::Discovery { command } => match command { 589 MycDiscoveryCommand::VerifyBundle { dir } => { 590 let output = verify_bundle(dir)?; 591 print_json(&output) 592 } 593 MycDiscoveryCommand::InspectLiveNip89 => { 594 let runtime = MycRuntime::bootstrap(config.clone())?; 595 let output = fetch_live_nip89(&runtime).await?; 596 print_json(&output) 597 } 598 MycDiscoveryCommand::DiffLiveNip89 => { 599 let runtime = MycRuntime::bootstrap(config.clone())?; 600 let output = diff_live_nip89(&runtime).await?; 601 print_json(&output) 602 } 603 MycDiscoveryCommand::RefreshNip89 { force } => { 604 let runtime = MycRuntime::bootstrap(config.clone())?; 605 let output = refresh_nip89(&runtime, force).await?; 606 print_json(&output) 607 } 608 MycDiscoveryCommand::RenderNip05 { out, stdout } => { 609 let runtime = MycRuntime::bootstrap(config.clone())?; 610 if stdout && out.is_some() { 611 return Err(MycError::InvalidOperation( 612 "discovery render-nip05 cannot use --stdout and --out together".to_owned(), 613 )); 614 } 615 let context = MycDiscoveryContext::from_runtime(&runtime)?; 616 if stdout || (out.is_none() && context.nip05_output_path().is_none()) { 617 println!("{}", context.render_nip05_json_pretty()?); 618 Ok(()) 619 } else { 620 let output = context.write_nip05_document( 621 out.as_deref().or(context.nip05_output_path()).ok_or_else(|| { 622 MycError::InvalidOperation( 623 "discovery render-nip05 requires --out or discovery.nip05_output_path" 624 .to_owned(), 625 ) 626 })?, 627 )?; 628 print_json(&output) 629 } 630 } 631 MycDiscoveryCommand::RenderNip89 => { 632 let runtime = MycRuntime::bootstrap(config.clone())?; 633 let output = MycDiscoveryContext::from_runtime(&runtime)?.render_nip89_output()?; 634 print_json(&output) 635 } 636 MycDiscoveryCommand::PublishNip89 => { 637 let runtime = MycRuntime::bootstrap(config.clone())?; 638 let output = publish_nip89_event(&runtime).await?; 639 print_json(&output) 640 } 641 MycDiscoveryCommand::ExportBundle { out } => { 642 let runtime = MycRuntime::bootstrap(config)?; 643 let output = MycDiscoveryContext::from_runtime(&runtime)?.write_bundle(out)?; 644 print_json(&output) 645 } 646 }, 647 } 648 } 649 650 fn load_config(path: Option<&Path>) -> Result<MycConfig, MycError> { 651 match path { 652 Some(path) => MycConfig::load_from_env_path(path), 653 None => MycConfig::load_from_default_env_path(), 654 } 655 } 656 657 fn custody_provider_for_command( 658 config: &MycConfig, 659 command: &MycCustodyCommand, 660 ) -> Result<crate::custody::MycIdentityProvider, MycError> { 661 let role = match command { 662 MycCustodyCommand::Status { role } 663 | MycCustodyCommand::List { role } 664 | MycCustodyCommand::Generate { role, .. } 665 | MycCustodyCommand::ImportFile { role, .. } 666 | MycCustodyCommand::ExportNip49 { role, .. } 667 | MycCustodyCommand::ImportNip49 { role, .. } 668 | MycCustodyCommand::Rotate { role } 669 | MycCustodyCommand::Select { role, .. } 670 | MycCustodyCommand::Remove { role, .. } => *role, 671 }; 672 673 custody_provider_for_role(config, role) 674 } 675 676 fn custody_provider_for_role( 677 config: &MycConfig, 678 role: MycCustodyRole, 679 ) -> Result<crate::custody::MycIdentityProvider, MycError> { 680 match role { 681 MycCustodyRole::Signer => crate::custody::MycIdentityProvider::from_source( 682 "signer", 683 config.paths.signer_identity_source(), 684 Duration::from_secs(config.custody.external_command_timeout_secs), 685 ), 686 MycCustodyRole::User => crate::custody::MycIdentityProvider::from_source( 687 "user", 688 config.paths.user_identity_source(), 689 Duration::from_secs(config.custody.external_command_timeout_secs), 690 ), 691 MycCustodyRole::DiscoveryApp => { 692 let Some(source) = config.discovery.app_identity_source() else { 693 return Err(MycError::InvalidOperation( 694 "discovery app identity is not separately configured; it currently reuses the signer identity".to_owned(), 695 )); 696 }; 697 crate::custody::MycIdentityProvider::from_source( 698 "discovery app", 699 source, 700 Duration::from_secs(config.custody.external_command_timeout_secs), 701 ) 702 } 703 } 704 } 705 706 fn parse_connection_id(value: &str) -> Result<RadrootsNostrSignerConnectionId, MycError> { 707 Ok(RadrootsNostrSignerConnectionId::parse(value)?) 708 } 709 710 fn granted_permissions_for_approval( 711 policy: &crate::policy::MycPolicyContext, 712 connections: &[RadrootsNostrSignerConnectionRecord], 713 connection_id: &RadrootsNostrSignerConnectionId, 714 grants: &[String], 715 ) -> Result<RadrootsNostrConnectPermissions, MycError> { 716 if !grants.is_empty() { 717 return policy.validate_operator_grants(parse_permission_values(grants)?); 718 } 719 720 let connection = connections 721 .iter() 722 .find(|connection| &connection.connection_id == connection_id) 723 .ok_or_else(|| { 724 MycError::InvalidOperation(format!("connection `{connection_id}` was not found")) 725 })?; 726 policy.validate_operator_grants(connection.requested_permissions.clone()) 727 } 728 729 fn load_audit_output( 730 runtime: &MycRuntime, 731 manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager, 732 connection_id: Option<&str>, 733 attempt_id: Option<&str>, 734 scope: MycAuditScope, 735 limit: Option<usize>, 736 ) -> Result<MycAuditListOutput, MycError> { 737 if connection_id.is_some() && attempt_id.is_some() { 738 return Err(MycError::InvalidOperation( 739 "audit commands cannot filter by both connection_id and attempt_id".to_owned(), 740 )); 741 } 742 if attempt_id.is_some() && scope == MycAuditScope::Request { 743 return Err(MycError::InvalidOperation( 744 "audit attempt lookup only supports operation or all scope".to_owned(), 745 )); 746 } 747 748 let limit = audit_read_limit(runtime, limit); 749 let connection_id = connection_id.map(parse_connection_id).transpose()?; 750 let signer_request_audit = match (scope, connection_id.as_ref()) { 751 (MycAuditScope::Operation, _) => Vec::new(), 752 (_, Some(connection_id)) => manager 753 .audit_records_for_connection(connection_id)? 754 .into_iter() 755 .rev() 756 .take(limit) 757 .collect::<Vec<_>>() 758 .into_iter() 759 .rev() 760 .collect(), 761 (_, None) => manager 762 .list_audit_records()? 763 .into_iter() 764 .rev() 765 .take(limit) 766 .collect::<Vec<_>>() 767 .into_iter() 768 .rev() 769 .collect(), 770 }; 771 let runtime_operation_audit = match (scope, connection_id.as_ref(), attempt_id) { 772 (MycAuditScope::Request, _, _) => Vec::new(), 773 (_, Some(connection_id), _) => runtime 774 .operation_audit_store() 775 .list_for_connection_with_limit(connection_id, limit)?, 776 (_, None, Some(attempt_id)) => runtime 777 .operation_audit_store() 778 .list_for_attempt_id_with_limit(attempt_id, limit)?, 779 (_, None, None) => runtime.operation_audit_store().list_with_limit(limit)?, 780 }; 781 782 Ok(MycAuditListOutput { 783 signer_request_audit, 784 runtime_operation_audit, 785 }) 786 } 787 788 fn summarize_audit_output( 789 runtime: &MycRuntime, 790 manager: &radroots_nostr_signer::prelude::RadrootsNostrSignerManager, 791 connection_id: Option<&str>, 792 attempt_id: Option<&str>, 793 scope: MycAuditScope, 794 limit: Option<usize>, 795 ) -> Result<MycAuditSummaryOutput, MycError> { 796 let record_limit = audit_read_limit(runtime, limit); 797 let audit = load_audit_output( 798 runtime, 799 manager, 800 connection_id, 801 attempt_id, 802 scope, 803 Some(record_limit), 804 )?; 805 let mut signer_request_decisions = MycAuditDecisionCounts::default(); 806 for record in &audit.signer_request_audit { 807 match record.decision { 808 radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Allowed => { 809 signer_request_decisions.allowed += 1; 810 } 811 radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Denied => { 812 signer_request_decisions.denied += 1; 813 } 814 radroots_nostr_signer::prelude::RadrootsNostrSignerRequestDecision::Challenged => { 815 signer_request_decisions.challenged += 1; 816 } 817 } 818 } 819 820 let mut runtime_operation_outcomes = MycOperationOutcomeCounts::default(); 821 let mut runtime_operation_by_kind = BTreeMap::new(); 822 let mut runtime_aggregate_publish_rejection_count = 0; 823 let mut runtime_repair_success_count = 0; 824 let mut runtime_repair_rejection_count = 0; 825 let mut runtime_unavailable_count = 0; 826 let mut runtime_replay_restore_count = 0; 827 for record in &audit.runtime_operation_audit { 828 increment_outcome_counts(&mut runtime_operation_outcomes, record.outcome); 829 let key = operation_kind_label(record.operation); 830 increment_outcome_counts( 831 runtime_operation_by_kind.entry(key).or_default(), 832 record.outcome, 833 ); 834 if is_aggregate_publish_operation(record.operation) 835 && record.outcome == MycOperationAuditOutcome::Rejected 836 { 837 runtime_aggregate_publish_rejection_count += 1; 838 } 839 if record.operation == MycOperationAuditKind::DiscoveryHandlerRepair { 840 match record.outcome { 841 MycOperationAuditOutcome::Succeeded => runtime_repair_success_count += 1, 842 MycOperationAuditOutcome::Rejected => runtime_repair_rejection_count += 1, 843 _ => {} 844 } 845 } 846 if record.outcome == MycOperationAuditOutcome::Unavailable { 847 runtime_unavailable_count += 1; 848 } 849 if record.operation == MycOperationAuditKind::AuthReplayRestore 850 && record.outcome == MycOperationAuditOutcome::Restored 851 { 852 runtime_replay_restore_count += 1; 853 } 854 } 855 856 Ok(MycAuditSummaryOutput { 857 record_limit, 858 signer_request_total: audit.signer_request_audit.len(), 859 signer_request_decisions, 860 runtime_operation_total: audit.runtime_operation_audit.len(), 861 runtime_operation_outcomes, 862 runtime_operation_by_kind, 863 runtime_aggregate_publish_rejection_count, 864 runtime_repair_success_count, 865 runtime_repair_rejection_count, 866 runtime_unavailable_count, 867 runtime_replay_restore_count, 868 }) 869 } 870 871 fn load_latest_discovery_repair_attempt_output( 872 runtime: &MycRuntime, 873 view: MycDiscoveryRepairAttemptView, 874 ) -> Result<MycDiscoveryRepairAttemptOutput, MycError> { 875 let attempt_id = runtime 876 .operation_audit_store() 877 .latest_attempt_id_for_operation(MycOperationAuditKind::DiscoveryHandlerRefresh)? 878 .ok_or_else(|| { 879 MycError::InvalidOperation("no discovery repair attempts have been recorded".to_owned()) 880 })?; 881 load_discovery_repair_attempt_output(runtime, attempt_id.as_str(), view) 882 } 883 884 fn load_discovery_repair_attempt_output( 885 runtime: &MycRuntime, 886 attempt_id: &str, 887 view: MycDiscoveryRepairAttemptView, 888 ) -> Result<MycDiscoveryRepairAttemptOutput, MycError> { 889 let records = runtime 890 .operation_audit_store() 891 .list_for_attempt_id(attempt_id)?; 892 if records.is_empty() { 893 return Err(MycError::InvalidOperation(format!( 894 "discovery repair attempt `{attempt_id}` was not found" 895 ))); 896 } 897 898 match view { 899 MycDiscoveryRepairAttemptView::Summary => Ok(MycDiscoveryRepairAttemptOutput::Summary( 900 MycDiscoveryRepairAttemptSummaryOutput::from_records(attempt_id, &records)?, 901 )), 902 MycDiscoveryRepairAttemptView::Records => Ok(MycDiscoveryRepairAttemptOutput::Records( 903 MycDiscoveryRepairAttemptRecordsOutput { 904 attempt_id: attempt_id.to_owned(), 905 runtime_operation_audit: records, 906 }, 907 )), 908 } 909 } 910 911 fn audit_read_limit(runtime: &MycRuntime, limit: Option<usize>) -> usize { 912 limit.unwrap_or(runtime.operation_audit_store().config().default_read_limit) 913 } 914 915 impl MycDiscoveryRepairAttemptSummaryOutput { 916 fn from_records( 917 attempt_id: &str, 918 records: &[MycOperationAuditRecord], 919 ) -> Result<Self, MycError> { 920 let Some(first_record) = records.first() else { 921 return Err(MycError::InvalidOperation(format!( 922 "discovery repair attempt `{attempt_id}` had no records" 923 ))); 924 }; 925 let finished_at_unix = records 926 .last() 927 .map(|record| record.recorded_at_unix) 928 .unwrap_or(first_record.recorded_at_unix); 929 let compare_outcome = records.iter().find_map(|record| { 930 (record.operation == MycOperationAuditKind::DiscoveryHandlerCompare) 931 .then_some(record.outcome) 932 }); 933 let refresh_outcome = records.iter().rev().find_map(|record| { 934 (record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh) 935 .then_some(record.outcome) 936 }); 937 let refresh_record = records 938 .iter() 939 .rev() 940 .find(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerRefresh); 941 let publish_record = records 942 .iter() 943 .rev() 944 .find(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerPublish); 945 946 let mut repair_summary = MycDiscoveryRepairSummary::default(); 947 let mut failed_relays = Vec::new(); 948 for record in records 949 .iter() 950 .filter(|record| record.operation == MycOperationAuditKind::DiscoveryHandlerRepair) 951 { 952 match record.outcome { 953 MycOperationAuditOutcome::Succeeded => repair_summary.repaired += 1, 954 MycOperationAuditOutcome::Rejected => { 955 repair_summary.failed += 1; 956 if let Some(relay_url) = record.relay_url.clone() { 957 failed_relays.push(relay_url); 958 } 959 } 960 MycOperationAuditOutcome::Matched => repair_summary.unchanged += 1, 961 MycOperationAuditOutcome::Skipped => repair_summary.skipped += 1, 962 _ => {} 963 } 964 } 965 failed_relays.sort(); 966 failed_relays.dedup(); 967 let planned_repair_relays = refresh_record 968 .map(|record| record.planned_repair_relays.clone()) 969 .unwrap_or_default(); 970 let blocked_relays = refresh_record 971 .map(|record| record.blocked_relays.clone()) 972 .unwrap_or_default(); 973 let blocked_reason = refresh_record.and_then(|record| record.blocked_reason.clone()); 974 let remaining_repair_relays = if !failed_relays.is_empty() { 975 failed_relays.clone() 976 } else if matches!( 977 refresh_outcome, 978 Some( 979 MycOperationAuditOutcome::Unavailable 980 | MycOperationAuditOutcome::Conflicted 981 | MycOperationAuditOutcome::Rejected 982 ) 983 ) { 984 planned_repair_relays.clone() 985 } else { 986 Vec::new() 987 }; 988 989 Ok(Self { 990 attempt_id: attempt_id.to_owned(), 991 record_count: records.len(), 992 started_at_unix: first_record.recorded_at_unix, 993 finished_at_unix, 994 compare_outcome, 995 refresh_outcome, 996 aggregate_publish_outcome: publish_record.map(|record| record.outcome), 997 aggregate_publish_relay_count: publish_record.map(|record| record.relay_count), 998 aggregate_publish_acknowledged_relay_count: publish_record 999 .map(|record| record.acknowledged_relay_count), 1000 aggregate_publish_relay_outcome_summary: publish_record 1001 .map(|record| record.relay_outcome_summary.clone()), 1002 aggregate_publish_delivery_policy: publish_record 1003 .and_then(|record| record.delivery_policy), 1004 aggregate_publish_required_acknowledged_relay_count: publish_record 1005 .and_then(|record| record.required_acknowledged_relay_count), 1006 aggregate_publish_attempt_count: publish_record 1007 .and_then(|record| record.publish_attempt_count), 1008 repair_summary, 1009 planned_repair_relays, 1010 blocked_relays, 1011 blocked_reason, 1012 failed_relays: failed_relays.clone(), 1013 remaining_repair_relays, 1014 }) 1015 } 1016 } 1017 1018 fn print_json<T>(value: &T) -> Result<(), MycError> 1019 where 1020 T: Serialize, 1021 { 1022 println!("{}", serde_json::to_string_pretty(value)?); 1023 Ok(()) 1024 } 1025 1026 fn print_text(value: &str) { 1027 println!("{value}"); 1028 } 1029 1030 fn read_secret_env(name: &str, operation: &str) -> Result<Zeroizing<String>, MycError> { 1031 let value = std::env::var(name).map_err(|_| { 1032 MycError::InvalidOperation(format!( 1033 "{operation} requires environment variable `{name}` to be set" 1034 )) 1035 })?; 1036 if value.is_empty() { 1037 return Err(MycError::InvalidOperation(format!( 1038 "{operation} requires environment variable `{name}` to be non-empty" 1039 ))); 1040 } 1041 Ok(Zeroizing::new(value)) 1042 } 1043 1044 #[cfg(test)] 1045 mod tests { 1046 use std::path::PathBuf; 1047 1048 use clap::Parser; 1049 use nostr::Timestamp; 1050 use radroots_identity::RadrootsIdentity; 1051 use radroots_nostr_connect::prelude::RadrootsNostrConnectRequest; 1052 use radroots_nostr_signer::prelude::RadrootsNostrSignerConnectionDraft; 1053 use serde_json::json; 1054 1055 use crate::audit::{MycOperationAuditKind, MycOperationAuditOutcome, MycOperationAuditRecord}; 1056 use crate::config::MycConfig; 1057 1058 use super::{ 1059 MycAuditScope, MycCli, MycCommand, MycCustodyCommand, MycCustodyRole, MycStatusView, 1060 granted_permissions_for_approval, load_audit_output, summarize_audit_output, 1061 }; 1062 use crate::app::MycRuntime; 1063 1064 fn write_identity(path: &std::path::Path, secret_key: &str) { 1065 let identity = RadrootsIdentity::from_secret_key_str(secret_key).expect("identity"); 1066 crate::identity_files::store_encrypted_identity(path, &identity).expect("save identity"); 1067 } 1068 1069 fn runtime() -> MycRuntime { 1070 runtime_with_config(|_| {}) 1071 } 1072 1073 fn runtime_with_config<F>(configure: F) -> MycRuntime 1074 where 1075 F: FnOnce(&mut MycConfig), 1076 { 1077 let temp = tempfile::tempdir().expect("tempdir").keep(); 1078 let mut config = MycConfig::default(); 1079 config.audit.default_read_limit = 2; 1080 config.paths.state_dir = PathBuf::from(&temp).join("state"); 1081 config.paths.signer_identity_path = PathBuf::from(&temp).join("signer.json"); 1082 config.paths.user_identity_path = PathBuf::from(&temp).join("user.json"); 1083 configure(&mut config); 1084 write_identity( 1085 &config.paths.signer_identity_path, 1086 "1111111111111111111111111111111111111111111111111111111111111111", 1087 ); 1088 write_identity( 1089 &config.paths.user_identity_path, 1090 "2222222222222222222222222222222222222222222222222222222222222222", 1091 ); 1092 MycRuntime::bootstrap(config).expect("runtime") 1093 } 1094 1095 #[test] 1096 fn granted_permissions_for_approval_respects_policy_ceiling() { 1097 let runtime = runtime_with_config(|config| { 1098 config.policy.permission_ceiling = "nip04_encrypt".parse().expect("permission ceiling"); 1099 }); 1100 let manager = runtime.signer_manager().expect("manager"); 1101 let connection = manager 1102 .register_connection( 1103 RadrootsNostrSignerConnectionDraft::new( 1104 nostr::Keys::generate().public_key(), 1105 runtime.user_public_identity(), 1106 ) 1107 .with_requested_permissions( 1108 "nip44_encrypt".parse().expect("requested permissions"), 1109 ), 1110 ) 1111 .expect("register connection"); 1112 1113 let error = granted_permissions_for_approval( 1114 runtime.signer_context().policy(), 1115 &manager.list_connections().expect("connections"), 1116 &connection.connection_id, 1117 &[], 1118 ) 1119 .expect_err("requested permissions outside policy should be rejected"); 1120 1121 assert!( 1122 error 1123 .to_string() 1124 .contains("granted permissions exceed the configured policy ceiling") 1125 ); 1126 } 1127 1128 #[test] 1129 fn audit_output_surfaces_both_request_and_operation_records() { 1130 let runtime = runtime(); 1131 let manager = runtime.signer_manager().expect("manager"); 1132 let connection = manager 1133 .register_connection(RadrootsNostrSignerConnectionDraft::new( 1134 nostr::Keys::generate().public_key(), 1135 runtime.user_public_identity(), 1136 )) 1137 .expect("register connection"); 1138 let request_evaluation = manager 1139 .evaluate_request( 1140 &connection.connection_id, 1141 radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new( 1142 "request-1", 1143 RadrootsNostrConnectRequest::Ping, 1144 ), 1145 ) 1146 .expect("record audit"); 1147 runtime.record_operation_audit(&MycOperationAuditRecord::new( 1148 MycOperationAuditKind::AuthReplayRestore, 1149 MycOperationAuditOutcome::Restored, 1150 Some(&connection.connection_id), 1151 Some(request_evaluation.audit.request_id.as_str()), 1152 1, 1153 0, 1154 "restored pending auth challenge after replay failure", 1155 )); 1156 1157 let output = load_audit_output( 1158 &runtime, 1159 &manager, 1160 Some(connection.connection_id.as_str()), 1161 None, 1162 MycAuditScope::All, 1163 None, 1164 ) 1165 .expect("load audit output"); 1166 1167 assert_eq!(output.signer_request_audit, vec![request_evaluation.audit]); 1168 assert_eq!(output.runtime_operation_audit.len(), 1); 1169 assert_eq!( 1170 output.runtime_operation_audit[0].operation, 1171 MycOperationAuditKind::AuthReplayRestore 1172 ); 1173 } 1174 1175 #[test] 1176 fn audit_summary_counts_recent_failures_and_restores() { 1177 let runtime = runtime(); 1178 let manager = runtime.signer_manager().expect("manager"); 1179 let connection = manager 1180 .register_connection(RadrootsNostrSignerConnectionDraft::new( 1181 nostr::Keys::generate().public_key(), 1182 runtime.user_public_identity(), 1183 )) 1184 .expect("register connection"); 1185 1186 let denied = manager 1187 .evaluate_request( 1188 &connection.connection_id, 1189 radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new( 1190 "request-1", 1191 RadrootsNostrConnectRequest::SignEvent( 1192 serde_json::from_value(json!({ 1193 "pubkey": runtime.user_identity().public_key().to_hex(), 1194 "created_at": Timestamp::from(1).as_secs(), 1195 "kind": 1, 1196 "tags": [], 1197 "content": "hello" 1198 })) 1199 .expect("unsigned event"), 1200 ), 1201 ), 1202 ) 1203 .expect("denied request"); 1204 let challenged = manager 1205 .require_auth_challenge(&connection.connection_id, "https://auth.example") 1206 .expect("require auth challenge"); 1207 let challenged_eval = manager 1208 .evaluate_request( 1209 &challenged.connection_id, 1210 radroots_nostr_connect::prelude::RadrootsNostrConnectRequestMessage::new( 1211 "request-2", 1212 RadrootsNostrConnectRequest::Ping, 1213 ), 1214 ) 1215 .expect("challenged request"); 1216 1217 runtime.record_operation_audit(&MycOperationAuditRecord::new( 1218 MycOperationAuditKind::ListenerResponsePublish, 1219 MycOperationAuditOutcome::Rejected, 1220 Some(&connection.connection_id), 1221 Some("request-1"), 1222 1, 1223 0, 1224 "listener publish rejected", 1225 )); 1226 runtime.record_operation_audit(&MycOperationAuditRecord::new( 1227 MycOperationAuditKind::AuthReplayRestore, 1228 MycOperationAuditOutcome::Restored, 1229 Some(&connection.connection_id), 1230 Some("request-2"), 1231 1, 1232 0, 1233 "restored pending auth challenge after replay failure", 1234 )); 1235 runtime.record_operation_audit(&MycOperationAuditRecord::new( 1236 MycOperationAuditKind::ConnectAcceptPublish, 1237 MycOperationAuditOutcome::Succeeded, 1238 Some(&connection.connection_id), 1239 Some("request-3"), 1240 1, 1241 1, 1242 "publish succeeded", 1243 )); 1244 1245 let summary = summarize_audit_output( 1246 &runtime, 1247 &manager, 1248 Some(connection.connection_id.as_str()), 1249 None, 1250 MycAuditScope::All, 1251 None, 1252 ) 1253 .expect("summary"); 1254 1255 assert_eq!(summary.record_limit, 2); 1256 assert_eq!(summary.signer_request_total, 2); 1257 assert_eq!(summary.signer_request_decisions.denied, 1); 1258 assert_eq!(summary.signer_request_decisions.challenged, 1); 1259 assert_eq!(summary.runtime_operation_total, 2); 1260 assert_eq!(summary.runtime_operation_outcomes.succeeded, 1); 1261 assert_eq!(summary.runtime_operation_outcomes.restored, 1); 1262 assert_eq!(summary.runtime_aggregate_publish_rejection_count, 0); 1263 assert_eq!(summary.runtime_repair_success_count, 0); 1264 assert_eq!(summary.runtime_repair_rejection_count, 0); 1265 assert_eq!(summary.runtime_unavailable_count, 0); 1266 assert_eq!(summary.runtime_replay_restore_count, 1); 1267 assert_eq!( 1268 summary 1269 .runtime_operation_by_kind 1270 .get("auth_replay_restore") 1271 .expect("restore kind") 1272 .restored, 1273 1 1274 ); 1275 assert_eq!( 1276 summary 1277 .runtime_operation_by_kind 1278 .get("connect_accept_publish") 1279 .expect("connect kind") 1280 .succeeded, 1281 1 1282 ); 1283 assert_eq!(denied.audit.request_id.as_str(), "request-1"); 1284 assert_eq!(challenged_eval.audit.request_id.as_str(), "request-2"); 1285 } 1286 1287 #[test] 1288 fn audit_summary_separates_repair_rejections_from_aggregate_publish_rejections() { 1289 let runtime = runtime(); 1290 let manager = runtime.signer_manager().expect("manager"); 1291 let connection = manager 1292 .register_connection(RadrootsNostrSignerConnectionDraft::new( 1293 nostr::Keys::generate().public_key(), 1294 runtime.user_public_identity(), 1295 )) 1296 .expect("register connection"); 1297 1298 runtime.record_operation_audit(&MycOperationAuditRecord::new( 1299 MycOperationAuditKind::DiscoveryHandlerPublish, 1300 MycOperationAuditOutcome::Succeeded, 1301 Some(&connection.connection_id), 1302 Some("request-1"), 1303 2, 1304 1, 1305 "1/2 relays acknowledged publish; failures: relay-b: blocked", 1306 )); 1307 runtime.record_operation_audit( 1308 &MycOperationAuditRecord::new( 1309 MycOperationAuditKind::DiscoveryHandlerRepair, 1310 MycOperationAuditOutcome::Succeeded, 1311 Some(&connection.connection_id), 1312 Some("request-1"), 1313 1, 1314 1, 1315 "relay repaired", 1316 ) 1317 .with_relay_url("wss://relay-a.example.com"), 1318 ); 1319 runtime.record_operation_audit( 1320 &MycOperationAuditRecord::new( 1321 MycOperationAuditKind::DiscoveryHandlerRepair, 1322 MycOperationAuditOutcome::Rejected, 1323 Some(&connection.connection_id), 1324 Some("request-1"), 1325 1, 1326 0, 1327 "blocked by relay", 1328 ) 1329 .with_relay_url("wss://relay-b.example.com"), 1330 ); 1331 runtime.record_operation_audit(&MycOperationAuditRecord::new( 1332 MycOperationAuditKind::ListenerResponsePublish, 1333 MycOperationAuditOutcome::Rejected, 1334 Some(&connection.connection_id), 1335 Some("request-2"), 1336 1, 1337 0, 1338 "listener publish rejected", 1339 )); 1340 1341 let summary = summarize_audit_output( 1342 &runtime, 1343 &manager, 1344 Some(connection.connection_id.as_str()), 1345 None, 1346 MycAuditScope::Operation, 1347 Some(10), 1348 ) 1349 .expect("summary"); 1350 1351 assert_eq!(summary.runtime_operation_total, 4); 1352 assert_eq!(summary.runtime_aggregate_publish_rejection_count, 1); 1353 assert_eq!(summary.runtime_repair_success_count, 1); 1354 assert_eq!(summary.runtime_repair_rejection_count, 1); 1355 assert_eq!(summary.runtime_replay_restore_count, 0); 1356 assert_eq!( 1357 summary 1358 .runtime_operation_by_kind 1359 .get("discovery_handler_publish") 1360 .expect("publish kind") 1361 .succeeded, 1362 1 1363 ); 1364 assert_eq!( 1365 summary 1366 .runtime_operation_by_kind 1367 .get("discovery_handler_repair") 1368 .expect("repair kind") 1369 .succeeded, 1370 1 1371 ); 1372 assert_eq!( 1373 summary 1374 .runtime_operation_by_kind 1375 .get("discovery_handler_repair") 1376 .expect("repair kind") 1377 .rejected, 1378 1 1379 ); 1380 } 1381 1382 #[test] 1383 fn parses_signer_status_view() { 1384 let cli = MycCli::try_parse_from(["myc", "status", "--view", "signer"]) 1385 .expect("parse signer status"); 1386 1387 assert!(matches!( 1388 cli.command, 1389 Some(MycCommand::Status { 1390 view: MycStatusView::Signer 1391 }) 1392 )); 1393 } 1394 1395 #[test] 1396 fn parses_custody_list_command() { 1397 let status = MycCli::try_parse_from(["myc", "custody", "status", "--role", "signer"]) 1398 .expect("parse custody status"); 1399 assert!(matches!( 1400 status.command, 1401 Some(MycCommand::Custody { 1402 command: MycCustodyCommand::Status { 1403 role: MycCustodyRole::Signer 1404 } 1405 }) 1406 )); 1407 1408 let cli = MycCli::try_parse_from(["myc", "custody", "list", "--role", "signer"]) 1409 .expect("parse custody list"); 1410 1411 assert!(matches!( 1412 cli.command, 1413 Some(MycCommand::Custody { 1414 command: MycCustodyCommand::List { 1415 role: MycCustodyRole::Signer 1416 } 1417 }) 1418 )); 1419 } 1420 1421 #[test] 1422 fn parses_custody_generate_and_import_commands() { 1423 let generate = MycCli::try_parse_from([ 1424 "myc", "custody", "generate", "--role", "user", "--label", "primary", "--select", 1425 ]) 1426 .expect("parse custody generate"); 1427 assert!(matches!( 1428 generate.command, 1429 Some(MycCommand::Custody { 1430 command: MycCustodyCommand::Generate { 1431 role: MycCustodyRole::User, 1432 select: true, 1433 .. 1434 } 1435 }) 1436 )); 1437 1438 let import = MycCli::try_parse_from([ 1439 "myc", 1440 "custody", 1441 "import-file", 1442 "--role", 1443 "discovery-app", 1444 "--path", 1445 "/tmp/discovery.json", 1446 ]) 1447 .expect("parse custody import"); 1448 assert!(matches!( 1449 import.command, 1450 Some(MycCommand::Custody { 1451 command: MycCustodyCommand::ImportFile { 1452 role: MycCustodyRole::DiscoveryApp, 1453 select: false, 1454 .. 1455 } 1456 }) 1457 )); 1458 1459 let export_nip49 = MycCli::try_parse_from([ 1460 "myc", 1461 "custody", 1462 "export-nip49", 1463 "--role", 1464 "signer", 1465 "--out", 1466 "/tmp/signer.ncryptsec", 1467 "--password-env", 1468 "MYC_TEST_PASSWORD", 1469 ]) 1470 .expect("parse custody export-nip49"); 1471 assert!(matches!( 1472 export_nip49.command, 1473 Some(MycCommand::Custody { 1474 command: MycCustodyCommand::ExportNip49 { 1475 role: MycCustodyRole::Signer, 1476 .. 1477 } 1478 }) 1479 )); 1480 1481 let import_nip49 = MycCli::try_parse_from([ 1482 "myc", 1483 "custody", 1484 "import-nip49", 1485 "--role", 1486 "user", 1487 "--path", 1488 "/tmp/user.ncryptsec", 1489 "--password-env", 1490 "MYC_TEST_PASSWORD", 1491 "--label", 1492 "migrated", 1493 ]) 1494 .expect("parse custody import-nip49"); 1495 assert!(matches!( 1496 import_nip49.command, 1497 Some(MycCommand::Custody { 1498 command: MycCustodyCommand::ImportNip49 { 1499 role: MycCustodyRole::User, 1500 .. 1501 } 1502 }) 1503 )); 1504 1505 let rotate = 1506 MycCli::try_parse_from(["myc", "custody", "rotate", "--role", "discovery-app"]) 1507 .expect("parse custody rotate"); 1508 assert!(matches!( 1509 rotate.command, 1510 Some(MycCommand::Custody { 1511 command: MycCustodyCommand::Rotate { 1512 role: MycCustodyRole::DiscoveryApp 1513 } 1514 }) 1515 )); 1516 } 1517 }