a simple rust terminal ui (tui) for setting up alternative plc rotation keys driven by: secure enclave hardware (not synced) or software-based keys (synced to icloud)
plc secure-enclave touchid icloud atproto
at main 2259 lines 75 kB view raw
1use anyhow::Result; 2use ratatui::crossterm::event::{self, KeyCode, KeyEvent, KeyModifiers}; 3use ratatui::widgets::ListState; 4use std::collections::HashSet; 5use tokio::sync::mpsc; 6 7use crate::atproto::PdsSession; 8use crate::directory::PlcDirectoryClient; 9use crate::enclave::EnclaveKey; 10use crate::event::AppMessage; 11use crate::plc::{self, OperationDiff, PlcOperation, PlcState}; 12 13#[derive(Debug, Clone, Copy, PartialEq)] 14pub enum ActiveTab { 15 Keys, 16 Identity, 17 Sign, 18 Audit, 19 Post, 20 Login, 21} 22 23impl ActiveTab { 24 pub fn index(&self) -> usize { 25 match self { 26 ActiveTab::Keys => 0, 27 ActiveTab::Identity => 1, 28 ActiveTab::Sign => 2, 29 ActiveTab::Audit => 3, 30 ActiveTab::Post => 4, 31 ActiveTab::Login => 5, 32 } 33 } 34} 35 36#[derive(Debug, Clone, PartialEq)] 37pub enum Modal { 38 None, 39 Help, 40 TouchId { message: String }, 41 Confirm { 42 title: String, 43 message: String, 44 options: Vec<(String, String)>, 45 }, 46 Error { message: String }, 47 Success { message: String }, 48 KeyGenForm { label: String, syncable: bool }, 49 TextInput { 50 title: String, 51 value: String, 52 target: TextInputTarget, 53 }, 54} 55 56#[derive(Debug, Clone, PartialEq)] 57pub enum TextInputTarget { 58 EditDid, 59 PlcToken, 60} 61 62#[derive(Debug, Clone, Copy, PartialEq)] 63pub enum InputMode { 64 Normal, 65 Editing, 66} 67 68pub struct App { 69 pub active_tab: ActiveTab, 70 pub modal: Modal, 71 pub input_mode: InputMode, 72 pub should_quit: bool, 73 74 // State 75 pub keys: Vec<EnclaveKey>, 76 pub active_key_index: Option<usize>, 77 pub current_did: Option<String>, 78 pub plc_state: Option<PlcState>, 79 pub audit_log: Option<Vec<serde_json::Value>>, 80 pub session: Option<PdsSession>, 81 pub last_prev_cid: Option<String>, 82 pub pending_rotation_keys: Option<Vec<String>>, 83 84 // UI state 85 pub key_list_state: ListState, 86 pub rotation_key_list_state: ListState, 87 pub audit_list_state: ListState, 88 pub expanded_audit_entries: HashSet<usize>, 89 pub post_textarea: tui_textarea::TextArea<'static>, 90 pub show_operation_json: bool, 91 pub sign_scroll: u16, 92 93 // Login form 94 pub login_handle: String, 95 pub login_password: String, 96 pub login_field: usize, // 0=handle, 1=password 97 98 // Pending operation 99 pub pending_operation: Option<PlcOperation>, 100 pub operation_diff: Option<OperationDiff>, 101 102 // Confirm action state 103 pub confirm_action: Option<ConfirmAction>, 104 105 // Async 106 pub loading: Option<String>, 107 msg_tx: mpsc::UnboundedSender<AppMessage>, 108 msg_rx: mpsc::UnboundedReceiver<AppMessage>, 109} 110 111#[derive(Debug, Clone)] 112pub enum ConfirmAction { 113 SubmitOperation, 114 DeleteKey(String), 115 Disconnect, 116} 117 118impl App { 119 pub fn new() -> Self { 120 let (msg_tx, msg_rx) = mpsc::unbounded_channel(); 121 let mut textarea = tui_textarea::TextArea::default(); 122 textarea.set_cursor_line_style(ratatui::style::Style::default()); 123 124 Self { 125 active_tab: ActiveTab::Keys, 126 modal: Modal::None, 127 input_mode: InputMode::Normal, 128 should_quit: false, 129 keys: Vec::new(), 130 active_key_index: None, 131 current_did: None, 132 plc_state: None, 133 audit_log: None, 134 session: None, 135 last_prev_cid: None, 136 pending_rotation_keys: None, 137 key_list_state: ListState::default(), 138 rotation_key_list_state: ListState::default(), 139 audit_list_state: ListState::default(), 140 expanded_audit_entries: HashSet::new(), 141 post_textarea: textarea, 142 show_operation_json: false, 143 sign_scroll: 0, 144 login_handle: String::new(), 145 login_password: String::new(), 146 login_field: 0, 147 pending_operation: None, 148 operation_diff: None, 149 confirm_action: None, 150 loading: None, 151 msg_tx, 152 msg_rx, 153 } 154 } 155 156 pub async fn run( 157 &mut self, 158 terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>, 159 ) -> Result<()> { 160 // Load saved session 161 if let Ok(Some(session)) = PdsSession::load() { 162 self.current_did = Some(session.did.clone()); 163 self.session = Some(session); 164 } 165 166 // Load keys on startup 167 self.spawn_load_keys(); 168 169 // If we have a DID, load PLC state 170 if let Some(did) = &self.current_did { 171 let did = did.clone(); 172 self.spawn_load_plc_state(&did); 173 } 174 175 // Dedicated thread for crossterm event polling 176 let event_tx = self.msg_tx.clone(); 177 std::thread::spawn(move || { 178 loop { 179 if event::poll(std::time::Duration::from_millis(50)).unwrap_or(false) { 180 if let Ok(evt) = event::read() { 181 if let event::Event::Key(key) = evt { 182 if event_tx.send(AppMessage::KeyEvent(key)).is_err() { 183 break; 184 } 185 } 186 } 187 } 188 } 189 }); 190 191 loop { 192 terminal.draw(|frame| self.render(frame))?; 193 194 if let Some(msg) = self.msg_rx.recv().await { 195 match msg { 196 AppMessage::KeyEvent(key) => self.handle_key_event(key), 197 other => self.handle_message(other), 198 } 199 } 200 201 if self.should_quit { 202 return Ok(()); 203 } 204 } 205 } 206 207 fn handle_key_event(&mut self, key: KeyEvent) { 208 // Modal takes priority 209 if self.modal != Modal::None { 210 self.handle_modal_key(key); 211 return; 212 } 213 214 // Editing mode for login form and post textarea 215 if self.input_mode == InputMode::Editing { 216 self.handle_editing_key(key); 217 return; 218 } 219 220 // Global bindings 221 match key.code { 222 KeyCode::Char('q') => self.should_quit = true, 223 KeyCode::Char('?') => self.modal = Modal::Help, 224 KeyCode::Char('1') => self.active_tab = ActiveTab::Keys, 225 KeyCode::Char('2') => self.active_tab = ActiveTab::Identity, 226 KeyCode::Char('3') => self.active_tab = ActiveTab::Sign, 227 KeyCode::Char('4') => self.active_tab = ActiveTab::Audit, 228 KeyCode::Char('5') => { 229 self.active_tab = ActiveTab::Post; 230 self.input_mode = InputMode::Editing; 231 } 232 KeyCode::Char('6') => { 233 self.active_tab = ActiveTab::Login; 234 if self.session.is_none() { 235 self.input_mode = InputMode::Editing; 236 } 237 } 238 _ => self.handle_tab_key(key), 239 } 240 } 241 242 fn handle_modal_key(&mut self, key: KeyEvent) { 243 match &self.modal { 244 Modal::Help => { 245 if key.code == KeyCode::Esc || key.code == KeyCode::Char('?') { 246 self.modal = Modal::None; 247 } 248 } 249 Modal::Error { .. } => { 250 if key.code == KeyCode::Esc || key.code == KeyCode::Enter { 251 self.modal = Modal::None; 252 } 253 } 254 Modal::Success { .. } => { 255 // Any key closes 256 self.modal = Modal::None; 257 } 258 Modal::TouchId { .. } => { 259 // Can't dismiss, waiting for Touch ID 260 } 261 Modal::Confirm { .. } => { 262 self.handle_confirm_key(key); 263 } 264 Modal::KeyGenForm { .. } => { 265 self.handle_keygen_key(key); 266 } 267 Modal::TextInput { .. } => { 268 self.handle_text_input_key(key); 269 } 270 Modal::None => {} 271 } 272 } 273 274 fn handle_confirm_key(&mut self, key: KeyEvent) { 275 match key.code { 276 KeyCode::Esc => { 277 self.modal = Modal::None; 278 self.confirm_action = None; 279 } 280 KeyCode::Char('y') => { 281 let action = self.confirm_action.take(); 282 self.modal = Modal::None; 283 if let Some(action) = action { 284 self.execute_confirm_action(action); 285 } 286 } 287 KeyCode::Char('n') | KeyCode::Char('f') => { 288 // For submit confirmation: 'f' saves to file (not yet implemented) 289 self.modal = Modal::None; 290 self.confirm_action = None; 291 } 292 _ => {} 293 } 294 } 295 296 fn execute_confirm_action(&mut self, action: ConfirmAction) { 297 match action { 298 ConfirmAction::SubmitOperation => { 299 self.submit_pending_operation(); 300 } 301 ConfirmAction::DeleteKey(label) => { 302 self.spawn_delete_key(&label); 303 } 304 ConfirmAction::Disconnect => { 305 let _ = PdsSession::delete(); 306 self.session = None; 307 } 308 } 309 } 310 311 fn handle_keygen_key(&mut self, key: KeyEvent) { 312 let (mut label, mut syncable) = match &self.modal { 313 Modal::KeyGenForm { label, syncable } => (label.clone(), *syncable), 314 _ => return, 315 }; 316 317 match key.code { 318 KeyCode::Esc => { 319 self.modal = Modal::None; 320 } 321 KeyCode::Enter => { 322 if !label.is_empty() { 323 self.modal = Modal::None; 324 self.spawn_generate_key(&label, syncable); 325 } 326 } 327 KeyCode::Backspace => { 328 label.pop(); 329 self.modal = Modal::KeyGenForm { label, syncable }; 330 } 331 KeyCode::Tab => { 332 syncable = !syncable; 333 self.modal = Modal::KeyGenForm { label, syncable }; 334 } 335 KeyCode::Char(c) => { 336 if c.is_alphanumeric() || c == '-' || c == '_' { 337 label.push(c); 338 self.modal = Modal::KeyGenForm { label, syncable }; 339 } 340 } 341 _ => {} 342 } 343 } 344 345 fn handle_text_input_key(&mut self, key: KeyEvent) { 346 let (title, mut value, target) = match &self.modal { 347 Modal::TextInput { title, value, target } => { 348 (title.clone(), value.clone(), target.clone()) 349 } 350 _ => return, 351 }; 352 353 match key.code { 354 KeyCode::Esc => { 355 self.modal = Modal::None; 356 } 357 KeyCode::Enter => { 358 self.modal = Modal::None; 359 self.handle_text_input_submit(&value, &target); 360 } 361 KeyCode::Backspace => { 362 value.pop(); 363 self.modal = Modal::TextInput { title, value, target }; 364 } 365 KeyCode::Char(c) => { 366 value.push(c); 367 self.modal = Modal::TextInput { title, value, target }; 368 } 369 _ => {} 370 } 371 } 372 373 fn handle_text_input_submit(&mut self, value: &str, target: &TextInputTarget) { 374 match target { 375 TextInputTarget::EditDid => { 376 let did = value.trim().to_string(); 377 if did.starts_with("did:plc:") { 378 self.current_did = Some(did.clone()); 379 self.spawn_load_plc_state(&did); 380 self.spawn_load_audit_log(&did); 381 } else { 382 self.modal = Modal::Error { 383 message: "Invalid DID: must start with 'did:plc:'".to_string(), 384 }; 385 } 386 } 387 TextInputTarget::PlcToken => { 388 let token = value.trim().to_string(); 389 if !token.is_empty() { 390 self.spawn_pds_sign_operation(&token); 391 } 392 } 393 } 394 } 395 396 fn handle_editing_key(&mut self, key: KeyEvent) { 397 match self.active_tab { 398 ActiveTab::Login => self.handle_login_editing(key), 399 ActiveTab::Post => self.handle_post_editing(key), 400 _ => { 401 if key.code == KeyCode::Esc { 402 self.input_mode = InputMode::Normal; 403 } 404 } 405 } 406 } 407 408 fn handle_login_editing(&mut self, key: KeyEvent) { 409 match key.code { 410 KeyCode::Esc => { 411 self.input_mode = InputMode::Normal; 412 } 413 KeyCode::Tab => { 414 self.login_field = (self.login_field + 1) % 2; 415 } 416 KeyCode::Enter => { 417 if !self.login_handle.is_empty() && !self.login_password.is_empty() { 418 self.input_mode = InputMode::Normal; 419 self.spawn_login(); 420 } 421 } 422 KeyCode::Backspace => { 423 if self.login_field == 0 { 424 self.login_handle.pop(); 425 } else { 426 self.login_password.pop(); 427 } 428 } 429 KeyCode::Char(c) => { 430 if self.login_field == 0 { 431 self.login_handle.push(c); 432 } else { 433 self.login_password.push(c); 434 } 435 } 436 _ => {} 437 } 438 } 439 440 fn handle_post_editing(&mut self, key: KeyEvent) { 441 if key.code == KeyCode::Esc { 442 self.input_mode = InputMode::Normal; 443 return; 444 } 445 446 // Ctrl+D to send (Ctrl+Enter is unreliable on macOS) 447 if key.code == KeyCode::Char('d') && key.modifiers.contains(KeyModifiers::CONTROL) { 448 let text = self.post_textarea.lines().join("\n"); 449 if !text.is_empty() && text.len() <= 300 { 450 self.input_mode = InputMode::Normal; 451 self.spawn_create_post(&text); 452 } 453 return; 454 } 455 456 // Forward to textarea 457 self.post_textarea.input(key); 458 } 459 460 fn handle_tab_key(&mut self, key: KeyEvent) { 461 match self.active_tab { 462 ActiveTab::Keys => self.handle_keys_key(key), 463 ActiveTab::Identity => self.handle_identity_key(key), 464 ActiveTab::Sign => self.handle_sign_key(key), 465 ActiveTab::Audit => self.handle_audit_key(key), 466 ActiveTab::Post => { 467 if key.code == KeyCode::Enter || key.code == KeyCode::Char('i') { 468 self.input_mode = InputMode::Editing; 469 } 470 } 471 ActiveTab::Login => self.handle_login_key(key), 472 } 473 } 474 475 fn handle_keys_key(&mut self, key: KeyEvent) { 476 match key.code { 477 KeyCode::Up => { 478 let len = self.keys.len(); 479 if len > 0 { 480 let i = self.key_list_state.selected().unwrap_or(0); 481 self.key_list_state.select(Some(if i == 0 { len - 1 } else { i - 1 })); 482 } 483 } 484 KeyCode::Down => { 485 let len = self.keys.len(); 486 if len > 0 { 487 let i = self.key_list_state.selected().unwrap_or(0); 488 self.key_list_state.select(Some((i + 1) % len)); 489 } 490 } 491 KeyCode::Char('n') => { 492 self.modal = Modal::KeyGenForm { 493 label: String::new(), 494 syncable: true, 495 }; 496 } 497 KeyCode::Char('d') => { 498 if let Some(i) = self.key_list_state.selected() { 499 if let Some(key) = self.keys.get(i) { 500 let label = key.label.clone(); 501 self.confirm_action = Some(ConfirmAction::DeleteKey(label.clone())); 502 self.modal = Modal::Confirm { 503 title: "Delete Key".to_string(), 504 message: format!("Delete key '{}'? This cannot be undone.", label), 505 options: vec![ 506 ("y".to_string(), "Delete".to_string()), 507 ("n".to_string(), "Cancel".to_string()), 508 ], 509 }; 510 } 511 } 512 } 513 KeyCode::Char('s') => { 514 if let Some(i) = self.key_list_state.selected() { 515 self.active_key_index = Some(i); 516 } 517 } 518 KeyCode::Enter => { 519 if let Some(i) = self.key_list_state.selected() { 520 if let Some(key) = self.keys.get(i) { 521 match arboard::Clipboard::new() { 522 Ok(mut clipboard) => { 523 let _ = clipboard.set_text(&key.did_key); 524 self.modal = Modal::Success { 525 message: format!("Copied did:key to clipboard"), 526 }; 527 } 528 Err(_) => { 529 self.modal = Modal::Error { 530 message: "Failed to access clipboard".to_string(), 531 }; 532 } 533 } 534 } 535 } 536 } 537 _ => {} 538 } 539 } 540 541 fn handle_identity_key(&mut self, key: KeyEvent) { 542 match key.code { 543 KeyCode::Up => { 544 if let Some(state) = &self.plc_state { 545 let len = state.rotation_keys.len(); 546 if len > 0 { 547 let i = self.rotation_key_list_state.selected().unwrap_or(0); 548 self.rotation_key_list_state.select(Some(if i == 0 { len - 1 } else { i - 1 })); 549 } 550 } 551 } 552 KeyCode::Down => { 553 if let Some(state) = &self.plc_state { 554 let len = state.rotation_keys.len(); 555 if len > 0 { 556 let i = self.rotation_key_list_state.selected().unwrap_or(0); 557 self.rotation_key_list_state.select(Some((i + 1) % len)); 558 } 559 } 560 } 561 KeyCode::Char('e') => { 562 self.modal = Modal::TextInput { 563 title: "Enter DID".to_string(), 564 value: self.current_did.clone().unwrap_or_default(), 565 target: TextInputTarget::EditDid, 566 }; 567 } 568 KeyCode::Char('r') => { 569 if let Some(did) = &self.current_did { 570 let did = did.clone(); 571 self.spawn_load_plc_state(&did); 572 self.spawn_load_audit_log(&did); 573 } 574 } 575 KeyCode::Char('a') => { 576 self.stage_add_key_operation(); 577 } 578 KeyCode::Char('m') => { 579 self.stage_move_key_operation(); 580 } 581 KeyCode::Char('x') => { 582 self.stage_remove_key_operation(); 583 } 584 _ => {} 585 } 586 } 587 588 fn handle_sign_key(&mut self, key: KeyEvent) { 589 match key.code { 590 KeyCode::Char('j') => { 591 self.show_operation_json = !self.show_operation_json; 592 } 593 KeyCode::Char('s') => { 594 if self.pending_operation.is_some() { 595 self.spawn_sign_operation(); 596 } 597 } 598 KeyCode::Up => { 599 self.sign_scroll = self.sign_scroll.saturating_sub(1); 600 } 601 KeyCode::Down => { 602 self.sign_scroll = self.sign_scroll.saturating_add(1); 603 } 604 KeyCode::Esc => { 605 self.pending_operation = None; 606 self.operation_diff = None; 607 } 608 _ => {} 609 } 610 } 611 612 fn handle_audit_key(&mut self, key: KeyEvent) { 613 match key.code { 614 KeyCode::Up => { 615 if let Some(log) = &self.audit_log { 616 let len = log.len(); 617 if len > 0 { 618 let i = self.audit_list_state.selected().unwrap_or(0); 619 self.audit_list_state.select(Some(if i == 0 { len - 1 } else { i - 1 })); 620 } 621 } 622 } 623 KeyCode::Down => { 624 if let Some(log) = &self.audit_log { 625 let len = log.len(); 626 if len > 0 { 627 let i = self.audit_list_state.selected().unwrap_or(0); 628 self.audit_list_state.select(Some((i + 1) % len)); 629 } 630 } 631 } 632 KeyCode::Enter | KeyCode::Char('j') => { 633 if let Some(i) = self.audit_list_state.selected() { 634 if self.expanded_audit_entries.contains(&i) { 635 self.expanded_audit_entries.remove(&i); 636 } else { 637 self.expanded_audit_entries.insert(i); 638 } 639 } 640 } 641 KeyCode::Char('r') => { 642 if let Some(did) = &self.current_did { 643 let did = did.clone(); 644 self.spawn_load_audit_log(&did); 645 } 646 } 647 _ => {} 648 } 649 } 650 651 fn handle_login_key(&mut self, key: KeyEvent) { 652 if self.session.is_some() { 653 match key.code { 654 KeyCode::Char('d') => { 655 self.confirm_action = Some(ConfirmAction::Disconnect); 656 self.modal = Modal::Confirm { 657 title: "Disconnect".to_string(), 658 message: "Disconnect from PDS?".to_string(), 659 options: vec![ 660 ("y".to_string(), "Disconnect".to_string()), 661 ("n".to_string(), "Cancel".to_string()), 662 ], 663 }; 664 } 665 KeyCode::Char('r') => { 666 self.spawn_refresh_session(); 667 } 668 _ => {} 669 } 670 } else { 671 // Enter editing mode 672 if key.code == KeyCode::Enter || key.code == KeyCode::Char('i') { 673 self.input_mode = InputMode::Editing; 674 } 675 } 676 } 677 678 // --- Operation staging --- 679 680 fn stage_add_key_operation(&mut self) { 681 let Some(state) = &self.plc_state else { 682 self.modal = Modal::Error { 683 message: "Load a DID first".to_string(), 684 }; 685 return; 686 }; 687 let Some(idx) = self.active_key_index else { 688 self.modal = Modal::Error { 689 message: "Select an active Secure Enclave key first (Tab 1, 's')".to_string(), 690 }; 691 return; 692 }; 693 let Some(key) = self.keys.get(idx) else { 694 return; 695 }; 696 697 // Check if SE key is already in rotation keys (can self-sign) 698 let se_key_in_rotation = state.rotation_keys.contains(&key.did_key); 699 700 // Add key at position 0 (highest priority) 701 let mut new_rotation_keys = vec![key.did_key.clone()]; 702 for existing in &state.rotation_keys { 703 if existing != &key.did_key { 704 new_rotation_keys.push(existing.clone()); 705 } 706 } 707 708 if se_key_in_rotation { 709 // Can self-sign with our SE key 710 let prev = self.last_prev_cid.clone().unwrap_or_default(); 711 let op = plc::build_update_operation(state, &prev, Some(new_rotation_keys), None, None, None); 712 let diff = plc::compute_diff(state, &op); 713 self.pending_operation = Some(op); 714 self.operation_diff = Some(diff); 715 self.active_tab = ActiveTab::Sign; 716 } else { 717 // Need PDS to sign — request token via email 718 let Some(session) = &self.session else { 719 self.modal = Modal::Error { 720 message: "Log in to your PDS first (Tab 6). Your SE key is not yet in rotation keys, so the PDS must sign.".to_string(), 721 }; 722 return; 723 }; 724 self.pending_rotation_keys = Some(new_rotation_keys); 725 self.spawn_request_plc_token(); 726 } 727 } 728 729 fn stage_move_key_operation(&mut self) { 730 let Some(state) = &self.plc_state else { 731 self.modal = Modal::Error { 732 message: "Load a DID first".to_string(), 733 }; 734 return; 735 }; 736 let Some(selected) = self.rotation_key_list_state.selected() else { 737 return; 738 }; 739 740 if selected == 0 || state.rotation_keys.len() < 2 { 741 return; 742 } 743 744 // Move selected key up by one position 745 let mut new_keys = state.rotation_keys.clone(); 746 new_keys.swap(selected, selected - 1); 747 748 let prev = self.last_prev_cid.clone().unwrap_or_default(); 749 let op = plc::build_update_operation(state, &prev, Some(new_keys), None, None, None); 750 let diff = plc::compute_diff(state, &op); 751 752 self.pending_operation = Some(op); 753 self.operation_diff = Some(diff); 754 self.active_tab = ActiveTab::Sign; 755 } 756 757 fn stage_remove_key_operation(&mut self) { 758 let Some(state) = &self.plc_state else { 759 self.modal = Modal::Error { 760 message: "Load a DID first".to_string(), 761 }; 762 return; 763 }; 764 let Some(selected) = self.rotation_key_list_state.selected() else { 765 return; 766 }; 767 768 if state.rotation_keys.len() <= 1 { 769 self.modal = Modal::Error { 770 message: "Cannot remove the last rotation key".to_string(), 771 }; 772 return; 773 } 774 775 let removed_key = &state.rotation_keys[selected]; 776 let new_keys: Vec<String> = state 777 .rotation_keys 778 .iter() 779 .enumerate() 780 .filter(|(i, _)| *i != selected) 781 .map(|(_, k)| k.clone()) 782 .collect(); 783 784 // Check if we have authority to self-sign 785 let can_self_sign = self.active_key_index.and_then(|idx| self.keys.get(idx)) 786 .map(|k| state.rotation_keys.contains(&k.did_key)) 787 .unwrap_or(false); 788 789 if can_self_sign { 790 let prev = self.last_prev_cid.clone().unwrap_or_default(); 791 let op = plc::build_update_operation(state, &prev, Some(new_keys), None, None, None); 792 let diff = plc::compute_diff(state, &op); 793 self.pending_operation = Some(op); 794 self.operation_diff = Some(diff); 795 self.active_tab = ActiveTab::Sign; 796 } else if self.session.is_some() { 797 self.pending_rotation_keys = Some(new_keys); 798 self.spawn_request_plc_token(); 799 } else { 800 self.modal = Modal::Error { 801 message: format!( 802 "Cannot remove key. Log in to PDS (Tab 6) or set an active SE key that's already in rotation (Tab 1, 's')." 803 ), 804 }; 805 } 806 } 807 808 // --- Async spawns --- 809 810 fn spawn_load_keys(&mut self) { 811 self.loading = Some("Loading keys".to_string()); 812 let tx = self.msg_tx.clone(); 813 tokio::task::spawn_blocking(move || { 814 let result = crate::enclave::list_keys() 815 .map_err(|e| e.to_string()); 816 let _ = tx.send(AppMessage::KeysLoaded(result)); 817 }); 818 } 819 820 fn spawn_generate_key(&mut self, label: &str, syncable: bool) { 821 self.loading = Some("Generating key".to_string()); 822 let tx = self.msg_tx.clone(); 823 let label = label.to_string(); 824 tokio::task::spawn_blocking(move || { 825 let result = crate::enclave::generate_key(&label, syncable) 826 .map_err(|e| e.to_string()); 827 let _ = tx.send(AppMessage::KeyGenerated(result)); 828 }); 829 } 830 831 fn spawn_delete_key(&mut self, label: &str) { 832 self.loading = Some("Deleting key".to_string()); 833 let tx = self.msg_tx.clone(); 834 let label = label.to_string(); 835 tokio::task::spawn_blocking(move || { 836 let result = crate::enclave::delete_key(&label) 837 .map(|_| label.clone()) 838 .map_err(|e| e.to_string()); 839 let _ = tx.send(AppMessage::KeyDeleted(result)); 840 }); 841 } 842 843 fn spawn_load_plc_state(&mut self, did: &str) { 844 self.loading = Some("Fetching PLC state".to_string()); 845 let tx = self.msg_tx.clone(); 846 let did = did.to_string(); 847 tokio::spawn(async move { 848 let client = PlcDirectoryClient::new(); 849 let result = client.get_state(&did).await.map_err(|e| e.to_string()); 850 let _ = tx.send(AppMessage::PlcStateLoaded(result)); 851 }); 852 } 853 854 fn spawn_load_audit_log(&mut self, did: &str) { 855 let tx = self.msg_tx.clone(); 856 let did = did.to_string(); 857 tokio::spawn(async move { 858 let client = PlcDirectoryClient::new(); 859 let result = client.get_audit_log(&did).await.map_err(|e| e.to_string()); 860 let _ = tx.send(AppMessage::AuditLogLoaded(result)); 861 }); 862 } 863 864 fn spawn_sign_operation(&mut self) { 865 let Some(op) = &self.pending_operation else { 866 return; 867 }; 868 let Some(idx) = self.active_key_index else { 869 self.modal = Modal::Error { 870 message: "No active key selected".to_string(), 871 }; 872 return; 873 }; 874 let Some(key) = self.keys.get(idx) else { 875 return; 876 }; 877 878 self.modal = Modal::TouchId { 879 message: "Place your finger on the sensor to sign this operation".to_string(), 880 }; 881 882 let tx = self.msg_tx.clone(); 883 let mut op = op.clone(); 884 let label = key.label.clone(); 885 let is_syncable = key.syncable; 886 887 tokio::task::spawn_blocking(move || { 888 let result = (|| -> Result<PlcOperation, String> { 889 let dag_cbor = plc::serialize_for_signing(&op).map_err(|e| e.to_string())?; 890 891 let sig = crate::sign::sign_operation(&dag_cbor, |data| { 892 crate::enclave::sign_with_key(&label, data, is_syncable) 893 }) 894 .map_err(|e| e.to_string())?; 895 896 op.sig = Some(sig); 897 Ok(op) 898 })(); 899 900 let _ = tx.send(AppMessage::OperationSigned(result)); 901 }); 902 } 903 904 fn spawn_request_plc_token(&mut self) { 905 let Some(session) = &self.session else { 906 return; 907 }; 908 self.loading = Some("Requesting PLC token (check email)".to_string()); 909 let tx = self.msg_tx.clone(); 910 let session = session.clone(); 911 912 tokio::spawn(async move { 913 let result = crate::atproto::request_plc_operation_signature(&session) 914 .await 915 .map_err(|e| e.to_string()); 916 let _ = tx.send(AppMessage::PlcTokenRequested(result)); 917 }); 918 } 919 920 fn spawn_pds_sign_operation(&mut self, token: &str) { 921 let Some(session) = &self.session else { 922 return; 923 }; 924 let Some(keys) = &self.pending_rotation_keys else { 925 return; 926 }; 927 self.loading = Some("PDS signing operation".to_string()); 928 let tx = self.msg_tx.clone(); 929 let session = session.clone(); 930 let token = token.to_string(); 931 let keys = keys.clone(); 932 933 tokio::spawn(async move { 934 let result = crate::atproto::sign_plc_operation(&session, &token, Some(keys)) 935 .await 936 .map_err(|e| e.to_string()); 937 let _ = tx.send(AppMessage::PdsPlcOperationSigned(result)); 938 }); 939 } 940 941 fn submit_pending_operation(&mut self) { 942 let Some(op) = &self.pending_operation else { 943 return; 944 }; 945 let Some(did) = &self.current_did else { 946 return; 947 }; 948 949 self.loading = Some("Submitting operation".to_string()); 950 let tx = self.msg_tx.clone(); 951 let op_json = serde_json::to_value(op).unwrap_or_default(); 952 let did = did.clone(); 953 954 tokio::spawn(async move { 955 let client = PlcDirectoryClient::new(); 956 let result = client 957 .submit_operation(&did, &op_json) 958 .await 959 .map_err(|e| e.to_string()); 960 let _ = tx.send(AppMessage::OperationSubmitted(result)); 961 }); 962 } 963 964 fn spawn_login(&mut self) { 965 self.loading = Some("Logging in".to_string()); 966 let tx = self.msg_tx.clone(); 967 let handle = self.login_handle.clone(); 968 let password = self.login_password.clone(); 969 970 tokio::spawn(async move { 971 let pds_endpoint = "https://bsky.social".to_string(); 972 let result = crate::atproto::create_session(&pds_endpoint, &handle, &password) 973 .await 974 .map_err(|e| e.to_string()); 975 let _ = tx.send(AppMessage::LoginResult(result)); 976 }); 977 } 978 979 fn spawn_refresh_session(&mut self) { 980 let Some(session) = &self.session else { 981 return; 982 }; 983 984 self.loading = Some("Refreshing session".to_string()); 985 let tx = self.msg_tx.clone(); 986 let session = session.clone(); 987 988 tokio::spawn(async move { 989 let result = crate::atproto::refresh_session(&session) 990 .await 991 .map_err(|e| e.to_string()); 992 let _ = tx.send(AppMessage::SessionRefreshed(result)); 993 }); 994 } 995 996 fn spawn_create_post(&mut self, text: &str) { 997 let Some(session) = &self.session else { 998 self.modal = Modal::Error { 999 message: "Not logged in".to_string(), 1000 }; 1001 return; 1002 }; 1003 1004 self.loading = Some("Creating post".to_string()); 1005 let tx = self.msg_tx.clone(); 1006 let session = session.clone(); 1007 let text = text.to_string(); 1008 1009 tokio::spawn(async move { 1010 let result = crate::atproto::create_post(&session, &text) 1011 .await 1012 .map_err(|e| e.to_string()); 1013 let _ = tx.send(AppMessage::PostCreated(result)); 1014 }); 1015 } 1016 1017 // --- Public test helpers --- 1018 1019 #[cfg(test)] 1020 pub fn send_key(&mut self, code: KeyCode) { 1021 self.handle_key_event(KeyEvent::new(code, KeyModifiers::empty())); 1022 } 1023 1024 #[cfg(test)] 1025 pub fn send_key_with_modifiers(&mut self, code: KeyCode, modifiers: KeyModifiers) { 1026 self.handle_key_event(KeyEvent::new(code, modifiers)); 1027 } 1028 1029 #[cfg(test)] 1030 pub fn inject_message(&mut self, msg: AppMessage) { 1031 self.handle_message(msg); 1032 } 1033 1034 // --- Message handling --- 1035 1036 fn handle_message(&mut self, msg: AppMessage) { 1037 self.loading = None; 1038 1039 match msg { 1040 AppMessage::KeyEvent(_) => {} // handled in run loop 1041 AppMessage::KeysLoaded(Ok(keys)) => { 1042 self.keys = keys; 1043 if !self.keys.is_empty() && self.key_list_state.selected().is_none() { 1044 self.key_list_state.select(Some(0)); 1045 } 1046 } 1047 AppMessage::KeysLoaded(Err(e)) => { 1048 self.modal = Modal::Error { 1049 message: format!("Failed to load keys: {}", e), 1050 }; 1051 } 1052 AppMessage::KeyGenerated(Ok(key)) => { 1053 self.keys.push(key); 1054 let idx = self.keys.len() - 1; 1055 self.key_list_state.select(Some(idx)); 1056 if self.active_key_index.is_none() { 1057 self.active_key_index = Some(idx); 1058 } 1059 self.modal = Modal::Success { 1060 message: "Key generated successfully".to_string(), 1061 }; 1062 } 1063 AppMessage::KeyGenerated(Err(e)) => { 1064 self.modal = Modal::Error { 1065 message: format!("Key generation failed: {}", e), 1066 }; 1067 } 1068 AppMessage::KeyDeleted(Ok(label)) => { 1069 self.keys.retain(|k| k.label != label); 1070 if self.keys.is_empty() { 1071 self.key_list_state.select(None); 1072 self.active_key_index = None; 1073 } else { 1074 let max = self.keys.len().saturating_sub(1); 1075 if let Some(sel) = self.key_list_state.selected() { 1076 if sel > max { 1077 self.key_list_state.select(Some(max)); 1078 } 1079 } 1080 if let Some(idx) = self.active_key_index { 1081 if idx >= self.keys.len() { 1082 self.active_key_index = Some(self.keys.len().saturating_sub(1)); 1083 } 1084 } 1085 } 1086 self.modal = Modal::Success { 1087 message: format!("Key '{}' deleted", label), 1088 }; 1089 } 1090 AppMessage::KeyDeleted(Err(e)) => { 1091 self.modal = Modal::Error { 1092 message: format!("Failed to delete key: {}", e), 1093 }; 1094 } 1095 AppMessage::PlcStateLoaded(Ok(state)) => { 1096 // Compute the CID of the latest operation for `prev` 1097 if let Some(log) = &self.audit_log { 1098 if let Some(last) = log.last() { 1099 if let Some(cid) = last.get("cid").and_then(|c| c.as_str()) { 1100 self.last_prev_cid = Some(cid.to_string()); 1101 } 1102 } 1103 } 1104 self.current_did = Some(state.did.clone()); 1105 self.plc_state = Some(state); 1106 } 1107 AppMessage::PlcStateLoaded(Err(e)) => { 1108 self.modal = Modal::Error { 1109 message: format!("Failed to load PLC state: {}", e), 1110 }; 1111 } 1112 AppMessage::AuditLogLoaded(Ok(log)) => { 1113 // Extract prev CID from last entry 1114 if let Some(last) = log.last() { 1115 if let Some(cid) = last.get("cid").and_then(|c| c.as_str()) { 1116 self.last_prev_cid = Some(cid.to_string()); 1117 } 1118 } 1119 self.audit_log = Some(log); 1120 self.expanded_audit_entries.clear(); 1121 if self.audit_list_state.selected().is_none() { 1122 self.audit_list_state.select(Some(0)); 1123 } 1124 } 1125 AppMessage::AuditLogLoaded(Err(e)) => { 1126 self.modal = Modal::Error { 1127 message: format!("Failed to load audit log: {}", e), 1128 }; 1129 } 1130 AppMessage::OperationSigned(Ok(signed_op)) => { 1131 self.pending_operation = Some(signed_op); 1132 self.confirm_action = Some(ConfirmAction::SubmitOperation); 1133 self.modal = Modal::Confirm { 1134 title: "Operation Signed".to_string(), 1135 message: "Submit to plc.directory?".to_string(), 1136 options: vec![ 1137 ("y".to_string(), "Submit now".to_string()), 1138 ("f".to_string(), "Save to file".to_string()), 1139 ("n".to_string(), "Cancel".to_string()), 1140 ], 1141 }; 1142 } 1143 AppMessage::OperationSigned(Err(e)) => { 1144 self.modal = Modal::Error { 1145 message: format!("Signing failed: {}", e), 1146 }; 1147 } 1148 AppMessage::OperationSubmitted(Ok(_)) => { 1149 self.pending_operation = None; 1150 self.operation_diff = None; 1151 self.modal = Modal::Success { 1152 message: "PLC operation submitted to plc.directory".to_string(), 1153 }; 1154 // Refresh state 1155 if let Some(did) = &self.current_did { 1156 let did = did.clone(); 1157 self.spawn_load_plc_state(&did); 1158 self.spawn_load_audit_log(&did); 1159 } 1160 } 1161 AppMessage::OperationSubmitted(Err(e)) => { 1162 self.modal = Modal::Error { 1163 message: format!("Submission failed: {}", e), 1164 }; 1165 } 1166 AppMessage::LoginResult(Ok(session)) => { 1167 self.current_did = Some(session.did.clone()); 1168 self.login_password.clear(); 1169 let did = session.did.clone(); 1170 self.session = Some(session); 1171 self.spawn_load_plc_state(&did); 1172 self.spawn_load_audit_log(&did); 1173 self.modal = Modal::Success { 1174 message: "Logged in successfully".to_string(), 1175 }; 1176 } 1177 AppMessage::LoginResult(Err(e)) => { 1178 self.login_password.clear(); 1179 self.modal = Modal::Error { 1180 message: format!("Login failed: {}", e), 1181 }; 1182 } 1183 AppMessage::SessionRefreshed(Ok(session)) => { 1184 self.session = Some(session); 1185 self.modal = Modal::Success { 1186 message: "Session refreshed".to_string(), 1187 }; 1188 } 1189 AppMessage::SessionRefreshed(Err(e)) => { 1190 self.modal = Modal::Error { 1191 message: format!("Session refresh failed: {}", e), 1192 }; 1193 } 1194 AppMessage::PostCreated(Ok(uri)) => { 1195 self.post_textarea = tui_textarea::TextArea::default(); 1196 self.modal = Modal::Success { 1197 message: format!("Post created: {}", uri), 1198 }; 1199 } 1200 AppMessage::PostCreated(Err(e)) => { 1201 self.modal = Modal::Error { 1202 message: format!("Post failed: {}", e), 1203 }; 1204 } 1205 AppMessage::PlcTokenRequested(Ok(())) => { 1206 self.modal = Modal::TextInput { 1207 title: "Enter PLC token from email".to_string(), 1208 value: String::new(), 1209 target: TextInputTarget::PlcToken, 1210 }; 1211 } 1212 AppMessage::PlcTokenRequested(Err(e)) => { 1213 self.pending_rotation_keys = None; 1214 self.modal = Modal::Error { 1215 message: format!("Failed to request token: {}", e), 1216 }; 1217 } 1218 AppMessage::PdsPlcOperationSigned(Ok(signed_resp)) => { 1219 // PDS returns {"operation": {the actual op}} — extract the inner operation 1220 let op = signed_resp.get("operation").cloned().unwrap_or(signed_resp); 1221 if let Some(did) = &self.current_did { 1222 self.loading = Some("Submitting PDS-signed operation".to_string()); 1223 let tx = self.msg_tx.clone(); 1224 let did = did.clone(); 1225 tokio::spawn(async move { 1226 let client = PlcDirectoryClient::new(); 1227 let result = client 1228 .submit_operation(&did, &op) 1229 .await 1230 .map_err(|e| e.to_string()); 1231 let _ = tx.send(AppMessage::OperationSubmitted(result)); 1232 }); 1233 } 1234 self.pending_rotation_keys = None; 1235 } 1236 AppMessage::PdsPlcOperationSigned(Err(e)) => { 1237 self.pending_rotation_keys = None; 1238 self.modal = Modal::Error { 1239 message: format!("PDS signing failed: {}", e), 1240 }; 1241 } 1242 } 1243 } 1244} 1245 1246#[cfg(test)] 1247mod tests { 1248 use super::*; 1249 use crate::enclave::EnclaveKey; 1250 use crate::plc::{PlcOperation, PlcService, PlcState}; 1251 use std::collections::BTreeMap; 1252 1253 fn make_app() -> App { 1254 App::new() 1255 } 1256 1257 fn make_test_key(label: &str) -> EnclaveKey { 1258 EnclaveKey { 1259 label: label.to_string(), 1260 did_key: format!("did:key:zTest{}", label), 1261 syncable: true, 1262 public_key_bytes: vec![0x04; 65], 1263 } 1264 } 1265 1266 fn make_test_state() -> PlcState { 1267 let mut services = BTreeMap::new(); 1268 services.insert( 1269 "atproto_pds".to_string(), 1270 PlcService { 1271 service_type: "AtprotoPersonalDataServer".to_string(), 1272 endpoint: "https://pds.example.com".to_string(), 1273 }, 1274 ); 1275 1276 PlcState { 1277 did: "did:plc:testdid123".to_string(), 1278 rotation_keys: vec![ 1279 "did:key:zRot1".to_string(), 1280 "did:key:zRot2".to_string(), 1281 "did:key:zRot3".to_string(), 1282 ], 1283 verification_methods: BTreeMap::new(), 1284 also_known_as: vec!["at://test.handle".to_string()], 1285 services, 1286 } 1287 } 1288 1289 fn make_test_session() -> PdsSession { 1290 PdsSession { 1291 did: "did:plc:testsession".to_string(), 1292 handle: "test.handle".to_string(), 1293 access_jwt: "access_token".to_string(), 1294 refresh_jwt: "refresh_token".to_string(), 1295 pds_endpoint: "https://bsky.social".to_string(), 1296 } 1297 } 1298 1299 // === ActiveTab tests === 1300 1301 #[test] 1302 fn test_active_tab_index() { 1303 assert_eq!(ActiveTab::Keys.index(), 0); 1304 assert_eq!(ActiveTab::Identity.index(), 1); 1305 assert_eq!(ActiveTab::Sign.index(), 2); 1306 assert_eq!(ActiveTab::Audit.index(), 3); 1307 assert_eq!(ActiveTab::Post.index(), 4); 1308 assert_eq!(ActiveTab::Login.index(), 5); 1309 } 1310 1311 // === App initialization tests === 1312 1313 #[test] 1314 fn test_app_new_defaults() { 1315 let app = make_app(); 1316 assert_eq!(app.active_tab, ActiveTab::Keys); 1317 assert_eq!(app.modal, Modal::None); 1318 assert_eq!(app.input_mode, InputMode::Normal); 1319 assert!(!app.should_quit); 1320 assert!(app.keys.is_empty()); 1321 assert!(app.active_key_index.is_none()); 1322 assert!(app.current_did.is_none()); 1323 assert!(app.plc_state.is_none()); 1324 assert!(app.audit_log.is_none()); 1325 assert!(app.session.is_none()); 1326 assert!(app.pending_operation.is_none()); 1327 assert!(app.loading.is_none()); 1328 assert!(!app.show_operation_json); 1329 assert_eq!(app.sign_scroll, 0); 1330 assert_eq!(app.login_field, 0); 1331 assert!(app.login_handle.is_empty()); 1332 assert!(app.login_password.is_empty()); 1333 } 1334 1335 // === Global keybinding tests === 1336 1337 #[test] 1338 fn test_quit() { 1339 let mut app = make_app(); 1340 app.send_key(KeyCode::Char('q')); 1341 assert!(app.should_quit); 1342 } 1343 1344 #[test] 1345 fn test_help_modal() { 1346 let mut app = make_app(); 1347 app.send_key(KeyCode::Char('?')); 1348 assert_eq!(app.modal, Modal::Help); 1349 } 1350 1351 #[test] 1352 fn test_tab_switching() { 1353 let mut app = make_app(); 1354 1355 app.send_key(KeyCode::Char('2')); 1356 assert_eq!(app.active_tab, ActiveTab::Identity); 1357 1358 app.send_key(KeyCode::Char('3')); 1359 assert_eq!(app.active_tab, ActiveTab::Sign); 1360 1361 app.send_key(KeyCode::Char('4')); 1362 assert_eq!(app.active_tab, ActiveTab::Audit); 1363 1364 app.send_key(KeyCode::Char('1')); 1365 assert_eq!(app.active_tab, ActiveTab::Keys); 1366 } 1367 1368 #[test] 1369 fn test_tab_5_enters_editing() { 1370 let mut app = make_app(); 1371 app.send_key(KeyCode::Char('5')); 1372 assert_eq!(app.active_tab, ActiveTab::Post); 1373 assert_eq!(app.input_mode, InputMode::Editing); 1374 } 1375 1376 #[test] 1377 fn test_tab_6_enters_editing_when_no_session() { 1378 let mut app = make_app(); 1379 app.send_key(KeyCode::Char('6')); 1380 assert_eq!(app.active_tab, ActiveTab::Login); 1381 assert_eq!(app.input_mode, InputMode::Editing); 1382 } 1383 1384 #[test] 1385 fn test_tab_6_stays_normal_when_logged_in() { 1386 let mut app = make_app(); 1387 app.session = Some(make_test_session()); 1388 app.send_key(KeyCode::Char('6')); 1389 assert_eq!(app.active_tab, ActiveTab::Login); 1390 assert_eq!(app.input_mode, InputMode::Normal); 1391 } 1392 1393 // === Modal tests === 1394 1395 #[test] 1396 fn test_help_modal_close_esc() { 1397 let mut app = make_app(); 1398 app.modal = Modal::Help; 1399 app.send_key(KeyCode::Esc); 1400 assert_eq!(app.modal, Modal::None); 1401 } 1402 1403 #[test] 1404 fn test_help_modal_close_question() { 1405 let mut app = make_app(); 1406 app.modal = Modal::Help; 1407 app.send_key(KeyCode::Char('?')); 1408 assert_eq!(app.modal, Modal::None); 1409 } 1410 1411 #[test] 1412 fn test_error_modal_close_esc() { 1413 let mut app = make_app(); 1414 app.modal = Modal::Error { 1415 message: "test error".to_string(), 1416 }; 1417 app.send_key(KeyCode::Esc); 1418 assert_eq!(app.modal, Modal::None); 1419 } 1420 1421 #[test] 1422 fn test_error_modal_close_enter() { 1423 let mut app = make_app(); 1424 app.modal = Modal::Error { 1425 message: "test error".to_string(), 1426 }; 1427 app.send_key(KeyCode::Enter); 1428 assert_eq!(app.modal, Modal::None); 1429 } 1430 1431 #[test] 1432 fn test_success_modal_close_any_key() { 1433 let mut app = make_app(); 1434 app.modal = Modal::Success { 1435 message: "done".to_string(), 1436 }; 1437 app.send_key(KeyCode::Char('x')); 1438 assert_eq!(app.modal, Modal::None); 1439 } 1440 1441 #[test] 1442 fn test_touchid_modal_not_dismissible() { 1443 let mut app = make_app(); 1444 app.modal = Modal::TouchId { 1445 message: "signing".to_string(), 1446 }; 1447 app.send_key(KeyCode::Esc); 1448 // Still showing TouchId modal 1449 assert!(matches!(app.modal, Modal::TouchId { .. })); 1450 } 1451 1452 #[test] 1453 fn test_modal_blocks_global_keys() { 1454 let mut app = make_app(); 1455 app.modal = Modal::Help; 1456 app.send_key(KeyCode::Char('q')); 1457 assert!(!app.should_quit, "q should not quit while modal is open"); 1458 assert_eq!(app.active_tab, ActiveTab::Keys, "tab should not change while modal is open"); 1459 } 1460 1461 // === KeyGen form tests === 1462 1463 #[test] 1464 fn test_keygen_form_typing() { 1465 let mut app = make_app(); 1466 app.modal = Modal::KeyGenForm { 1467 label: String::new(), 1468 syncable: true, 1469 }; 1470 1471 app.send_key(KeyCode::Char('m')); 1472 app.send_key(KeyCode::Char('y')); 1473 app.send_key(KeyCode::Char('-')); 1474 app.send_key(KeyCode::Char('k')); 1475 1476 match &app.modal { 1477 Modal::KeyGenForm { label, .. } => { 1478 assert_eq!(label, "my-k"); 1479 } 1480 _ => panic!("Expected KeyGenForm modal"), 1481 } 1482 } 1483 1484 #[test] 1485 fn test_keygen_form_backspace() { 1486 let mut app = make_app(); 1487 app.modal = Modal::KeyGenForm { 1488 label: "test".to_string(), 1489 syncable: true, 1490 }; 1491 1492 app.send_key(KeyCode::Backspace); 1493 1494 match &app.modal { 1495 Modal::KeyGenForm { label, .. } => assert_eq!(label, "tes"), 1496 _ => panic!("Expected KeyGenForm modal"), 1497 } 1498 } 1499 1500 #[test] 1501 fn test_keygen_form_rejects_special_chars() { 1502 let mut app = make_app(); 1503 app.modal = Modal::KeyGenForm { 1504 label: String::new(), 1505 syncable: true, 1506 }; 1507 1508 app.send_key(KeyCode::Char(' ')); 1509 app.send_key(KeyCode::Char('!')); 1510 app.send_key(KeyCode::Char('@')); 1511 1512 match &app.modal { 1513 Modal::KeyGenForm { label, .. } => assert!(label.is_empty()), 1514 _ => panic!("Expected KeyGenForm modal"), 1515 } 1516 } 1517 1518 #[test] 1519 fn test_keygen_form_esc_cancels() { 1520 let mut app = make_app(); 1521 app.modal = Modal::KeyGenForm { 1522 label: "test".to_string(), 1523 syncable: true, 1524 }; 1525 1526 app.send_key(KeyCode::Esc); 1527 assert_eq!(app.modal, Modal::None); 1528 } 1529 1530 #[test] 1531 fn test_keygen_form_enter_empty_does_nothing() { 1532 let mut app = make_app(); 1533 app.modal = Modal::KeyGenForm { 1534 label: String::new(), 1535 syncable: true, 1536 }; 1537 1538 app.send_key(KeyCode::Enter); 1539 // Modal should still be open (empty label) 1540 assert!(matches!(app.modal, Modal::KeyGenForm { .. })); 1541 } 1542 1543 // === Text input modal tests === 1544 1545 #[test] 1546 fn test_text_input_typing() { 1547 let mut app = make_app(); 1548 app.modal = Modal::TextInput { 1549 title: "Enter DID".to_string(), 1550 value: String::new(), 1551 target: TextInputTarget::EditDid, 1552 }; 1553 1554 app.send_key(KeyCode::Char('d')); 1555 app.send_key(KeyCode::Char('i')); 1556 app.send_key(KeyCode::Char('d')); 1557 1558 match &app.modal { 1559 Modal::TextInput { value, .. } => assert_eq!(value, "did"), 1560 _ => panic!("Expected TextInput modal"), 1561 } 1562 } 1563 1564 #[tokio::test] 1565 async fn test_text_input_submit_valid_did() { 1566 let mut app = make_app(); 1567 app.modal = Modal::TextInput { 1568 title: "Enter DID".to_string(), 1569 value: "did:plc:test123".to_string(), 1570 target: TextInputTarget::EditDid, 1571 }; 1572 1573 app.send_key(KeyCode::Enter); 1574 assert_eq!(app.modal, Modal::None); 1575 assert_eq!(app.current_did, Some("did:plc:test123".to_string())); 1576 } 1577 1578 #[test] 1579 fn test_text_input_submit_invalid_did() { 1580 let mut app = make_app(); 1581 app.modal = Modal::TextInput { 1582 title: "Enter DID".to_string(), 1583 value: "not-a-did".to_string(), 1584 target: TextInputTarget::EditDid, 1585 }; 1586 1587 app.send_key(KeyCode::Enter); 1588 assert!(matches!(app.modal, Modal::Error { .. })); 1589 } 1590 1591 // === Confirm modal tests === 1592 1593 #[test] 1594 fn test_confirm_esc_cancels() { 1595 let mut app = make_app(); 1596 app.confirm_action = Some(ConfirmAction::Disconnect); 1597 app.modal = Modal::Confirm { 1598 title: "Test".to_string(), 1599 message: "Confirm?".to_string(), 1600 options: vec![("y".to_string(), "Yes".to_string())], 1601 }; 1602 1603 app.send_key(KeyCode::Esc); 1604 assert_eq!(app.modal, Modal::None); 1605 assert!(app.confirm_action.is_none()); 1606 } 1607 1608 #[test] 1609 fn test_confirm_n_cancels() { 1610 let mut app = make_app(); 1611 app.confirm_action = Some(ConfirmAction::Disconnect); 1612 app.modal = Modal::Confirm { 1613 title: "Test".to_string(), 1614 message: "Confirm?".to_string(), 1615 options: vec![], 1616 }; 1617 1618 app.send_key(KeyCode::Char('n')); 1619 assert_eq!(app.modal, Modal::None); 1620 assert!(app.confirm_action.is_none()); 1621 } 1622 1623 // === Keys tab tests === 1624 1625 #[test] 1626 fn test_keys_navigation() { 1627 let mut app = make_app(); 1628 app.keys = vec![ 1629 make_test_key("key1"), 1630 make_test_key("key2"), 1631 make_test_key("key3"), 1632 ]; 1633 app.key_list_state.select(Some(0)); 1634 1635 app.send_key(KeyCode::Down); 1636 assert_eq!(app.key_list_state.selected(), Some(1)); 1637 1638 app.send_key(KeyCode::Down); 1639 assert_eq!(app.key_list_state.selected(), Some(2)); 1640 1641 app.send_key(KeyCode::Down); 1642 assert_eq!(app.key_list_state.selected(), Some(0)); // wraps 1643 1644 app.send_key(KeyCode::Up); 1645 assert_eq!(app.key_list_state.selected(), Some(2)); // wraps back 1646 } 1647 1648 #[test] 1649 fn test_keys_navigation_empty() { 1650 let mut app = make_app(); 1651 app.send_key(KeyCode::Down); // no crash 1652 app.send_key(KeyCode::Up); // no crash 1653 } 1654 1655 #[test] 1656 fn test_keys_new_opens_form() { 1657 let mut app = make_app(); 1658 app.send_key(KeyCode::Char('n')); 1659 assert!(matches!(app.modal, Modal::KeyGenForm { .. })); 1660 } 1661 1662 #[test] 1663 fn test_keys_set_active() { 1664 let mut app = make_app(); 1665 app.keys = vec![make_test_key("key1"), make_test_key("key2")]; 1666 app.key_list_state.select(Some(1)); 1667 1668 app.send_key(KeyCode::Char('s')); 1669 assert_eq!(app.active_key_index, Some(1)); 1670 } 1671 1672 #[test] 1673 fn test_keys_delete_opens_confirm() { 1674 let mut app = make_app(); 1675 app.keys = vec![make_test_key("mykey")]; 1676 app.key_list_state.select(Some(0)); 1677 1678 app.send_key(KeyCode::Char('d')); 1679 assert!(matches!(app.modal, Modal::Confirm { .. })); 1680 assert!(matches!(app.confirm_action, Some(ConfirmAction::DeleteKey(_)))); 1681 } 1682 1683 #[test] 1684 fn test_keys_delete_no_selection_does_nothing() { 1685 let mut app = make_app(); 1686 app.keys = vec![make_test_key("mykey")]; 1687 // No selection 1688 1689 app.send_key(KeyCode::Char('d')); 1690 assert_eq!(app.modal, Modal::None); 1691 } 1692 1693 // === Identity tab tests === 1694 1695 #[test] 1696 fn test_identity_edit_opens_text_input() { 1697 let mut app = make_app(); 1698 app.active_tab = ActiveTab::Identity; 1699 app.send_key(KeyCode::Char('e')); 1700 assert!(matches!(app.modal, Modal::TextInput { .. })); 1701 } 1702 1703 #[test] 1704 fn test_identity_rotation_key_navigation() { 1705 let mut app = make_app(); 1706 app.active_tab = ActiveTab::Identity; 1707 app.plc_state = Some(make_test_state()); 1708 1709 app.send_key(KeyCode::Down); 1710 assert_eq!(app.rotation_key_list_state.selected(), Some(1)); 1711 1712 app.send_key(KeyCode::Down); 1713 assert_eq!(app.rotation_key_list_state.selected(), Some(2)); 1714 1715 app.send_key(KeyCode::Up); 1716 assert_eq!(app.rotation_key_list_state.selected(), Some(1)); 1717 } 1718 1719 #[test] 1720 fn test_identity_add_key_no_state() { 1721 let mut app = make_app(); 1722 app.active_tab = ActiveTab::Identity; 1723 app.send_key(KeyCode::Char('a')); 1724 assert!(matches!(app.modal, Modal::Error { .. })); 1725 } 1726 1727 #[test] 1728 fn test_identity_add_key_no_active_key() { 1729 let mut app = make_app(); 1730 app.active_tab = ActiveTab::Identity; 1731 app.plc_state = Some(make_test_state()); 1732 app.send_key(KeyCode::Char('a')); 1733 assert!(matches!(app.modal, Modal::Error { .. })); 1734 } 1735 1736 #[test] 1737 fn test_identity_add_key_stages_operation() { 1738 let mut app = make_app(); 1739 app.active_tab = ActiveTab::Identity; 1740 // Put the SE key in rotation keys so self-sign path is taken 1741 let mut state = make_test_state(); 1742 state.rotation_keys.push("did:key:zTestmykey".to_string()); 1743 app.plc_state = Some(state); 1744 app.keys = vec![make_test_key("mykey")]; 1745 app.active_key_index = Some(0); 1746 app.last_prev_cid = Some("bafyprev".to_string()); 1747 1748 app.send_key(KeyCode::Char('a')); 1749 1750 assert_eq!(app.active_tab, ActiveTab::Sign); 1751 assert!(app.pending_operation.is_some()); 1752 assert!(app.operation_diff.is_some()); 1753 1754 let op = app.pending_operation.as_ref().unwrap(); 1755 assert_eq!(op.rotation_keys[0], "did:key:zTestmykey"); 1756 assert_eq!(op.prev, Some("bafyprev".to_string())); 1757 } 1758 1759 #[tokio::test] 1760 async fn test_identity_add_key_pds_flow_when_not_in_rotation() { 1761 let mut app = make_app(); 1762 app.active_tab = ActiveTab::Identity; 1763 app.plc_state = Some(make_test_state()); // SE key NOT in rotation 1764 app.keys = vec![make_test_key("mykey")]; 1765 app.active_key_index = Some(0); 1766 app.session = Some(make_test_session()); 1767 1768 app.send_key(KeyCode::Char('a')); 1769 1770 // Should NOT switch to Sign tab — goes to PDS token flow instead 1771 assert_eq!(app.active_tab, ActiveTab::Identity); 1772 assert!(app.pending_rotation_keys.is_some()); 1773 assert!(app.pending_operation.is_none()); 1774 } 1775 1776 #[test] 1777 fn test_identity_add_key_no_session_no_rotation() { 1778 let mut app = make_app(); 1779 app.active_tab = ActiveTab::Identity; 1780 app.plc_state = Some(make_test_state()); // SE key NOT in rotation 1781 app.keys = vec![make_test_key("mykey")]; 1782 app.active_key_index = Some(0); 1783 // No session 1784 1785 app.send_key(KeyCode::Char('a')); 1786 1787 // Should show error about needing to login 1788 assert!(matches!(app.modal, Modal::Error { .. })); 1789 } 1790 1791 #[test] 1792 fn test_identity_add_key_deduplicates() { 1793 let mut app = make_app(); 1794 app.active_tab = ActiveTab::Identity; 1795 1796 let mut state = make_test_state(); 1797 state.rotation_keys = vec!["did:key:zTestmykey".to_string(), "did:key:zOther".to_string()]; 1798 app.plc_state = Some(state); 1799 app.keys = vec![make_test_key("mykey")]; // did_key = "did:key:zTestmykey" 1800 app.active_key_index = Some(0); 1801 1802 app.send_key(KeyCode::Char('a')); 1803 1804 let op = app.pending_operation.as_ref().unwrap(); 1805 // Should not have duplicate 1806 let count = op.rotation_keys.iter().filter(|k| *k == "did:key:zTestmykey").count(); 1807 assert_eq!(count, 1); 1808 } 1809 1810 #[test] 1811 fn test_identity_move_key() { 1812 let mut app = make_app(); 1813 app.active_tab = ActiveTab::Identity; 1814 app.plc_state = Some(make_test_state()); 1815 app.rotation_key_list_state.select(Some(1)); // select key at index 1 1816 1817 app.send_key(KeyCode::Char('m')); 1818 1819 assert_eq!(app.active_tab, ActiveTab::Sign); 1820 let op = app.pending_operation.as_ref().unwrap(); 1821 // Key at index 1 should now be at index 0 1822 assert_eq!(op.rotation_keys[0], "did:key:zRot2"); 1823 assert_eq!(op.rotation_keys[1], "did:key:zRot1"); 1824 } 1825 1826 #[test] 1827 fn test_identity_move_key_at_top_does_nothing() { 1828 let mut app = make_app(); 1829 app.active_tab = ActiveTab::Identity; 1830 app.plc_state = Some(make_test_state()); 1831 app.rotation_key_list_state.select(Some(0)); // already at top 1832 1833 app.send_key(KeyCode::Char('m')); 1834 assert_eq!(app.active_tab, ActiveTab::Identity); // no change 1835 assert!(app.pending_operation.is_none()); 1836 } 1837 1838 // === Sign tab tests === 1839 1840 #[test] 1841 fn test_sign_toggle_json() { 1842 let mut app = make_app(); 1843 app.active_tab = ActiveTab::Sign; 1844 assert!(!app.show_operation_json); 1845 1846 app.send_key(KeyCode::Char('j')); 1847 assert!(app.show_operation_json); 1848 1849 app.send_key(KeyCode::Char('j')); 1850 assert!(!app.show_operation_json); 1851 } 1852 1853 #[test] 1854 fn test_sign_scroll() { 1855 let mut app = make_app(); 1856 app.active_tab = ActiveTab::Sign; 1857 1858 app.send_key(KeyCode::Down); 1859 assert_eq!(app.sign_scroll, 1); 1860 1861 app.send_key(KeyCode::Down); 1862 assert_eq!(app.sign_scroll, 2); 1863 1864 app.send_key(KeyCode::Up); 1865 assert_eq!(app.sign_scroll, 1); 1866 1867 app.send_key(KeyCode::Up); 1868 app.send_key(KeyCode::Up); // saturating 1869 assert_eq!(app.sign_scroll, 0); 1870 } 1871 1872 #[test] 1873 fn test_sign_esc_clears_operation() { 1874 let mut app = make_app(); 1875 app.active_tab = ActiveTab::Sign; 1876 app.pending_operation = Some(PlcOperation { 1877 op_type: "plc_operation".to_string(), 1878 rotation_keys: vec![], 1879 verification_methods: BTreeMap::new(), 1880 also_known_as: vec![], 1881 services: BTreeMap::new(), 1882 prev: None, 1883 sig: None, 1884 }); 1885 app.operation_diff = Some(crate::plc::OperationDiff { 1886 changes: vec![], 1887 }); 1888 1889 app.send_key(KeyCode::Esc); 1890 assert!(app.pending_operation.is_none()); 1891 assert!(app.operation_diff.is_none()); 1892 } 1893 1894 // === Audit tab tests === 1895 1896 #[test] 1897 fn test_audit_navigation() { 1898 let mut app = make_app(); 1899 app.active_tab = ActiveTab::Audit; 1900 app.audit_log = Some(vec![ 1901 serde_json::json!({"cid": "cid1"}), 1902 serde_json::json!({"cid": "cid2"}), 1903 serde_json::json!({"cid": "cid3"}), 1904 ]); 1905 app.audit_list_state.select(Some(0)); 1906 1907 app.send_key(KeyCode::Down); 1908 assert_eq!(app.audit_list_state.selected(), Some(1)); 1909 } 1910 1911 #[test] 1912 fn test_audit_expand_collapse() { 1913 let mut app = make_app(); 1914 app.active_tab = ActiveTab::Audit; 1915 app.audit_log = Some(vec![serde_json::json!({"cid": "cid1"})]); 1916 app.audit_list_state.select(Some(0)); 1917 1918 assert!(!app.expanded_audit_entries.contains(&0)); 1919 1920 app.send_key(KeyCode::Enter); 1921 assert!(app.expanded_audit_entries.contains(&0)); 1922 1923 app.send_key(KeyCode::Enter); 1924 assert!(!app.expanded_audit_entries.contains(&0)); 1925 } 1926 1927 // === Login editing tests === 1928 1929 #[test] 1930 fn test_login_editing_handle() { 1931 let mut app = make_app(); 1932 app.active_tab = ActiveTab::Login; 1933 app.input_mode = InputMode::Editing; 1934 app.login_field = 0; 1935 1936 app.send_key(KeyCode::Char('t')); 1937 app.send_key(KeyCode::Char('e')); 1938 app.send_key(KeyCode::Char('s')); 1939 app.send_key(KeyCode::Char('t')); 1940 1941 assert_eq!(app.login_handle, "test"); 1942 } 1943 1944 #[test] 1945 fn test_login_editing_password() { 1946 let mut app = make_app(); 1947 app.active_tab = ActiveTab::Login; 1948 app.input_mode = InputMode::Editing; 1949 app.login_field = 1; 1950 1951 app.send_key(KeyCode::Char('p')); 1952 app.send_key(KeyCode::Char('a')); 1953 app.send_key(KeyCode::Char('s')); 1954 app.send_key(KeyCode::Char('s')); 1955 1956 assert_eq!(app.login_password, "pass"); 1957 } 1958 1959 #[test] 1960 fn test_login_editing_tab_switches_field() { 1961 let mut app = make_app(); 1962 app.active_tab = ActiveTab::Login; 1963 app.input_mode = InputMode::Editing; 1964 assert_eq!(app.login_field, 0); 1965 1966 app.send_key(KeyCode::Tab); 1967 assert_eq!(app.login_field, 1); 1968 1969 app.send_key(KeyCode::Tab); 1970 assert_eq!(app.login_field, 0); 1971 } 1972 1973 #[test] 1974 fn test_login_editing_backspace() { 1975 let mut app = make_app(); 1976 app.active_tab = ActiveTab::Login; 1977 app.input_mode = InputMode::Editing; 1978 app.login_handle = "test".to_string(); 1979 1980 app.send_key(KeyCode::Backspace); 1981 assert_eq!(app.login_handle, "tes"); 1982 } 1983 1984 #[test] 1985 fn test_login_editing_esc() { 1986 let mut app = make_app(); 1987 app.active_tab = ActiveTab::Login; 1988 app.input_mode = InputMode::Editing; 1989 1990 app.send_key(KeyCode::Esc); 1991 assert_eq!(app.input_mode, InputMode::Normal); 1992 } 1993 1994 #[test] 1995 fn test_login_enter_empty_does_nothing() { 1996 let mut app = make_app(); 1997 app.active_tab = ActiveTab::Login; 1998 app.input_mode = InputMode::Editing; 1999 // Both fields empty 2000 2001 app.send_key(KeyCode::Enter); 2002 assert_eq!(app.input_mode, InputMode::Editing); // unchanged 2003 } 2004 2005 // === Login tab (normal mode) tests === 2006 2007 #[test] 2008 fn test_login_disconnect_opens_confirm() { 2009 let mut app = make_app(); 2010 app.active_tab = ActiveTab::Login; 2011 app.session = Some(make_test_session()); 2012 2013 app.send_key(KeyCode::Char('d')); 2014 assert!(matches!(app.modal, Modal::Confirm { .. })); 2015 } 2016 2017 #[test] 2018 fn test_login_enter_editing_when_no_session() { 2019 let mut app = make_app(); 2020 app.active_tab = ActiveTab::Login; 2021 2022 app.send_key(KeyCode::Enter); 2023 assert_eq!(app.input_mode, InputMode::Editing); 2024 } 2025 2026 // === Message handling tests === 2027 2028 #[test] 2029 fn test_handle_keys_loaded_ok() { 2030 let mut app = make_app(); 2031 app.loading = Some("Loading".to_string()); 2032 2033 app.inject_message(AppMessage::KeysLoaded(Ok(vec![ 2034 make_test_key("key1"), 2035 make_test_key("key2"), 2036 ]))); 2037 2038 assert!(app.loading.is_none()); 2039 assert_eq!(app.keys.len(), 2); 2040 assert_eq!(app.key_list_state.selected(), Some(0)); 2041 } 2042 2043 #[test] 2044 fn test_handle_keys_loaded_empty() { 2045 let mut app = make_app(); 2046 app.inject_message(AppMessage::KeysLoaded(Ok(vec![]))); 2047 2048 assert!(app.keys.is_empty()); 2049 assert!(app.key_list_state.selected().is_none()); 2050 } 2051 2052 #[test] 2053 fn test_handle_keys_loaded_err() { 2054 let mut app = make_app(); 2055 app.inject_message(AppMessage::KeysLoaded(Err("SE not available".to_string()))); 2056 2057 assert!(matches!(app.modal, Modal::Error { .. })); 2058 } 2059 2060 #[test] 2061 fn test_handle_key_generated() { 2062 let mut app = make_app(); 2063 app.inject_message(AppMessage::KeyGenerated(Ok(make_test_key("new")))); 2064 2065 assert_eq!(app.keys.len(), 1); 2066 assert_eq!(app.key_list_state.selected(), Some(0)); 2067 assert_eq!(app.active_key_index, Some(0)); 2068 assert!(matches!(app.modal, Modal::Success { .. })); 2069 } 2070 2071 #[test] 2072 fn test_handle_key_generated_preserves_active() { 2073 let mut app = make_app(); 2074 app.keys = vec![make_test_key("existing")]; 2075 app.active_key_index = Some(0); 2076 2077 app.inject_message(AppMessage::KeyGenerated(Ok(make_test_key("new")))); 2078 2079 assert_eq!(app.keys.len(), 2); 2080 assert_eq!(app.active_key_index, Some(0)); // not changed 2081 } 2082 2083 #[test] 2084 fn test_handle_key_deleted() { 2085 let mut app = make_app(); 2086 app.keys = vec![make_test_key("a"), make_test_key("b")]; 2087 app.key_list_state.select(Some(0)); 2088 app.active_key_index = Some(0); 2089 2090 app.inject_message(AppMessage::KeyDeleted(Ok("a".to_string()))); 2091 2092 assert_eq!(app.keys.len(), 1); 2093 assert_eq!(app.keys[0].label, "b"); 2094 assert!(matches!(app.modal, Modal::Success { .. })); 2095 } 2096 2097 #[test] 2098 fn test_handle_key_deleted_all() { 2099 let mut app = make_app(); 2100 app.keys = vec![make_test_key("only")]; 2101 app.key_list_state.select(Some(0)); 2102 app.active_key_index = Some(0); 2103 2104 app.inject_message(AppMessage::KeyDeleted(Ok("only".to_string()))); 2105 2106 assert!(app.keys.is_empty()); 2107 assert!(app.key_list_state.selected().is_none()); 2108 assert!(app.active_key_index.is_none()); 2109 } 2110 2111 #[test] 2112 fn test_handle_plc_state_loaded() { 2113 let mut app = make_app(); 2114 let state = make_test_state(); 2115 2116 app.inject_message(AppMessage::PlcStateLoaded(Ok(state))); 2117 2118 assert!(app.plc_state.is_some()); 2119 assert_eq!(app.current_did, Some("did:plc:testdid123".to_string())); 2120 } 2121 2122 #[test] 2123 fn test_handle_audit_log_loaded() { 2124 let mut app = make_app(); 2125 let log = vec![ 2126 serde_json::json!({"cid": "cid1"}), 2127 serde_json::json!({"cid": "cid2"}), 2128 ]; 2129 2130 app.inject_message(AppMessage::AuditLogLoaded(Ok(log))); 2131 2132 assert!(app.audit_log.is_some()); 2133 assert_eq!(app.audit_log.as_ref().unwrap().len(), 2); 2134 assert_eq!(app.last_prev_cid, Some("cid2".to_string())); 2135 assert_eq!(app.audit_list_state.selected(), Some(0)); 2136 assert!(app.expanded_audit_entries.is_empty()); 2137 } 2138 2139 #[test] 2140 fn test_handle_operation_signed() { 2141 let mut app = make_app(); 2142 let op = PlcOperation { 2143 op_type: "plc_operation".to_string(), 2144 rotation_keys: vec![], 2145 verification_methods: BTreeMap::new(), 2146 also_known_as: vec![], 2147 services: BTreeMap::new(), 2148 prev: None, 2149 sig: Some("signed!".to_string()), 2150 }; 2151 2152 app.inject_message(AppMessage::OperationSigned(Ok(op))); 2153 2154 assert!(app.pending_operation.is_some()); 2155 assert!(matches!(app.confirm_action, Some(ConfirmAction::SubmitOperation))); 2156 assert!(matches!(app.modal, Modal::Confirm { .. })); 2157 } 2158 2159 #[test] 2160 fn test_handle_operation_signed_err() { 2161 let mut app = make_app(); 2162 app.inject_message(AppMessage::OperationSigned(Err("cancelled".to_string()))); 2163 2164 assert!(matches!(app.modal, Modal::Error { .. })); 2165 } 2166 2167 #[test] 2168 fn test_handle_operation_submitted() { 2169 let mut app = make_app(); 2170 app.pending_operation = Some(PlcOperation { 2171 op_type: "plc_operation".to_string(), 2172 rotation_keys: vec![], 2173 verification_methods: BTreeMap::new(), 2174 also_known_as: vec![], 2175 services: BTreeMap::new(), 2176 prev: None, 2177 sig: Some("sig".to_string()), 2178 }); 2179 app.operation_diff = Some(crate::plc::OperationDiff { changes: vec![] }); 2180 2181 app.inject_message(AppMessage::OperationSubmitted(Ok("ok".to_string()))); 2182 2183 assert!(app.pending_operation.is_none()); 2184 assert!(app.operation_diff.is_none()); 2185 assert!(matches!(app.modal, Modal::Success { .. })); 2186 } 2187 2188 #[tokio::test] 2189 async fn test_handle_login_result_ok() { 2190 let mut app = make_app(); 2191 app.login_password = "secret".to_string(); 2192 2193 app.inject_message(AppMessage::LoginResult(Ok(make_test_session()))); 2194 2195 assert!(app.session.is_some()); 2196 assert_eq!(app.current_did, Some("did:plc:testsession".to_string())); 2197 assert!(app.login_password.is_empty(), "password should be cleared"); 2198 assert!(matches!(app.modal, Modal::Success { .. })); 2199 } 2200 2201 #[test] 2202 fn test_handle_login_result_err() { 2203 let mut app = make_app(); 2204 app.login_password = "wrong".to_string(); 2205 2206 app.inject_message(AppMessage::LoginResult(Err("bad creds".to_string()))); 2207 2208 assert!(app.session.is_none()); 2209 assert!(app.login_password.is_empty(), "password should be cleared on error too"); 2210 assert!(matches!(app.modal, Modal::Error { .. })); 2211 } 2212 2213 #[test] 2214 fn test_handle_session_refreshed() { 2215 let mut app = make_app(); 2216 let new_session = make_test_session(); 2217 app.inject_message(AppMessage::SessionRefreshed(Ok(new_session))); 2218 2219 assert!(app.session.is_some()); 2220 assert!(matches!(app.modal, Modal::Success { .. })); 2221 } 2222 2223 #[test] 2224 fn test_handle_post_created() { 2225 let mut app = make_app(); 2226 app.inject_message(AppMessage::PostCreated(Ok("at://did:plc:test/app.bsky.feed.post/abc".to_string()))); 2227 2228 assert!(matches!(app.modal, Modal::Success { .. })); 2229 } 2230 2231 #[test] 2232 fn test_handle_post_created_err() { 2233 let mut app = make_app(); 2234 app.inject_message(AppMessage::PostCreated(Err("unauthorized".to_string()))); 2235 2236 assert!(matches!(app.modal, Modal::Error { .. })); 2237 } 2238 2239 #[test] 2240 fn test_loading_cleared_on_message() { 2241 let mut app = make_app(); 2242 app.loading = Some("Doing stuff".to_string()); 2243 2244 app.inject_message(AppMessage::KeysLoaded(Ok(vec![]))); 2245 assert!(app.loading.is_none()); 2246 } 2247 2248 // === Post editing tests === 2249 2250 #[test] 2251 fn test_post_editing_esc() { 2252 let mut app = make_app(); 2253 app.active_tab = ActiveTab::Post; 2254 app.input_mode = InputMode::Editing; 2255 2256 app.send_key(KeyCode::Esc); 2257 assert_eq!(app.input_mode, InputMode::Normal); 2258 } 2259}