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
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}