tsafe-tui 1.0.10

Terminal UI for tsafe secret vault — full-screen browser with keyboard navigation, history viewer, quick-unlock
Documentation
//! F1-3: Bounded run-loop smoke harness.
//!
//! Drives `dispatch_input` with scripted input sequences and verifies the app
//! reaches the expected terminal state in a bounded number of steps — no real
//! terminal or event loop required.

use tsafe_tui::app::{App, EditFocus, LoginFocus, Screen, Theme};
use tsafe_tui::state::{dispatch_input, AppInput};
use zeroize::Zeroizing;

type PwStore = Option<Zeroizing<String>>;

fn pw(s: &str) -> PwStore {
    Some(Zeroizing::new(s.to_owned()))
}

fn no_pw() -> PwStore {
    None
}

/// Minimal App in Login state, no background threads.
fn login_app() -> App {
    App::new_for_test(vec!["default".into()], Screen::Login)
}

/// Minimal App in Dashboard state.
fn dashboard_app() -> App {
    let mut app = App::new_for_test(vec!["demo".into()], Screen::Dashboard);
    app.active_profile = Some("demo".into());
    app.secret_keys = vec!["API_KEY".into(), "DB_URL".into()];
    app
}

/// Feed a slice of inputs; return true if any step signals quit.
fn feed_until_quit(app: &mut App, inputs: &[AppInput], pw_store: &mut PwStore) -> bool {
    for input in inputs {
        if dispatch_input(app, input.clone(), pw_store) {
            return true;
        }
    }
    false
}

// ── Smoke: quit paths ─────────────────────────────────────────────────────────

#[test]
fn smoke_ctrl_c_quits_from_login() {
    let mut app = login_app();
    let mut store = no_pw();
    assert!(dispatch_input(&mut app, AppInput::CtrlC, &mut store));
}

#[test]
fn smoke_ctrl_c_quits_from_dashboard() {
    let mut app = dashboard_app();
    let mut store = pw("secret");
    assert!(dispatch_input(&mut app, AppInput::CtrlC, &mut store));
}

#[test]
fn smoke_q_quits_from_dashboard() {
    let mut app = dashboard_app();
    let mut store = pw("secret");
    assert!(dispatch_input(&mut app, AppInput::Char('q'), &mut store));
}

// ── Smoke: help overlay ───────────────────────────────────────────────────────

#[test]
fn smoke_help_overlay_open_close_never_quits() {
    let mut app = dashboard_app();
    let mut store = no_pw();
    let quit = feed_until_quit(
        &mut app,
        &[AppInput::Char('?'), AppInput::Char('?')],
        &mut store,
    );
    assert!(!quit);
    assert!(!app.help_visible);
    assert_eq!(app.screen, Screen::Dashboard);
}

#[test]
fn smoke_help_overlay_esc_closes() {
    let mut app = dashboard_app();
    app.help_visible = true;
    let mut store = no_pw();
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert!(!app.help_visible);
}

#[test]
fn smoke_help_blocks_q_while_visible() {
    let mut app = dashboard_app();
    app.help_visible = true;
    let mut store = no_pw();
    // 'q' while help is visible must NOT quit
    let quit = dispatch_input(&mut app, AppInput::Char('q'), &mut store);
    assert!(!quit);
    assert_eq!(app.screen, Screen::Dashboard);
}

// ── Smoke: search sequence ────────────────────────────────────────────────────

#[test]
fn smoke_search_mode_open_type_exit_esc() {
    let mut app = dashboard_app();
    let mut store = no_pw();
    dispatch_input(&mut app, AppInput::Char('/'), &mut store);
    assert!(app.search_mode);
    for c in "api".chars() {
        dispatch_input(&mut app, AppInput::Char(c), &mut store);
    }
    assert_eq!(app.search_query, "api");
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert!(!app.search_mode);
    assert_eq!(app.screen, Screen::Dashboard);
}

#[test]
fn smoke_search_enter_exits_search_mode() {
    let mut app = dashboard_app();
    let mut store = no_pw();
    dispatch_input(&mut app, AppInput::Char('/'), &mut store);
    dispatch_input(&mut app, AppInput::Char('d'), &mut store);
    dispatch_input(&mut app, AppInput::Enter, &mut store);
    assert!(!app.search_mode);
}

// ── Smoke: profile switch ─────────────────────────────────────────────────────

#[test]
fn smoke_profile_switch_returns_to_login() {
    let mut app = dashboard_app();
    let mut store = pw("pw");
    dispatch_input(&mut app, AppInput::Char('p'), &mut store);
    assert_eq!(app.screen, Screen::Login);
    assert!(app.active_profile.is_none());
    assert!(store.is_none());
}

// ── Smoke: new-secret modal ───────────────────────────────────────────────────

#[test]
fn smoke_new_secret_modal_open_cancel() {
    let mut app = dashboard_app();
    let mut store = pw("pw");
    dispatch_input(&mut app, AppInput::Char('n'), &mut store);
    assert!(matches!(app.screen, Screen::EditSecret { is_new: true }));
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert_eq!(app.screen, Screen::Dashboard);
}

#[test]
fn smoke_new_secret_tab_switches_focus() {
    let mut app = dashboard_app();
    let mut store = pw("pw");
    dispatch_input(&mut app, AppInput::Char('n'), &mut store);
    assert_eq!(app.edit_focus, EditFocus::Key);
    dispatch_input(&mut app, AppInput::Tab, &mut store);
    assert_eq!(app.edit_focus, EditFocus::Value);
    dispatch_input(&mut app, AppInput::Tab, &mut store);
    assert_eq!(app.edit_focus, EditFocus::Key);
}

#[test]
fn smoke_new_secret_type_key_and_value() {
    let mut app = dashboard_app();
    let mut store = pw("pw");
    dispatch_input(&mut app, AppInput::Char('n'), &mut store);
    for c in "MY_KEY".chars() {
        dispatch_input(&mut app, AppInput::Char(c), &mut store);
    }
    assert_eq!(app.edit_key, "MY_KEY");
    dispatch_input(&mut app, AppInput::Tab, &mut store);
    for c in "hunter2".chars() {
        dispatch_input(&mut app, AppInput::Char(c), &mut store);
    }
    assert_eq!(app.edit_value, "hunter2");
}

// ── Smoke: audit log ──────────────────────────────────────────────────────────

#[test]
fn smoke_audit_log_navigate_and_return() {
    let mut app = dashboard_app();
    app.screen = Screen::AuditLog;
    app.audit_lines = vec!["line1".into(), "line2".into(), "line3".into()];
    app.audit_scroll = 0;
    let mut store = no_pw();
    dispatch_input(&mut app, AppInput::Char('j'), &mut store);
    dispatch_input(&mut app, AppInput::Char('j'), &mut store);
    assert_eq!(app.audit_scroll, 2);
    // Can't scroll further
    dispatch_input(&mut app, AppInput::Down, &mut store);
    assert_eq!(app.audit_scroll, 2);
    dispatch_input(&mut app, AppInput::Char('k'), &mut store);
    assert_eq!(app.audit_scroll, 1);
    dispatch_input(&mut app, AppInput::Char('q'), &mut store);
    assert_eq!(app.screen, Screen::Dashboard);
}

// ── Smoke: new-profile wizard cancel ─────────────────────────────────────────

#[test]
fn smoke_new_profile_wizard_open_and_cancel() {
    let mut app = App::new_for_test(vec![], Screen::Login);
    let mut store = no_pw();
    // No profiles → focus is Profile; 'n' opens wizard
    dispatch_input(&mut app, AppInput::Char('n'), &mut store);
    assert!(matches!(app.screen, Screen::NewProfile { step: 0 }));
    for c in "testapp".chars() {
        dispatch_input(&mut app, AppInput::Char(c), &mut store);
    }
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert_eq!(app.screen, Screen::Login);
}

// ── Smoke: rotate password cancel ────────────────────────────────────────────

#[test]
fn smoke_rotate_password_type_and_cancel() {
    let mut app = dashboard_app();
    app.screen = Screen::RotatePassword;
    app.rotate_step = 0;
    let mut store = pw("old");
    for c in "newpw123".chars() {
        dispatch_input(&mut app, AppInput::Char(c), &mut store);
    }
    dispatch_input(&mut app, AppInput::Enter, &mut store);
    assert_eq!(app.rotate_step, 1);
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert_eq!(app.screen, Screen::Dashboard);
}

// ── Smoke: cursor navigation wrapping ────────────────────────────────────────

#[test]
fn smoke_dashboard_cursor_wraps_down_and_up() {
    let mut app = dashboard_app();
    let mut store = no_pw();
    let n = app.visible_entries().len();
    assert!(n > 0);
    for _ in 0..n {
        dispatch_input(&mut app, AppInput::Down, &mut store);
    }
    assert_eq!(app.secret_cursor, 0);
    dispatch_input(&mut app, AppInput::Up, &mut store);
    assert_eq!(app.secret_cursor, n - 1);
}

// ── Smoke: theme cycle ────────────────────────────────────────────────────────

#[test]
fn smoke_theme_cycles_all_three() {
    let mut app = dashboard_app();
    assert_eq!(app.theme, Theme::Dark);
    let mut store = no_pw();
    dispatch_input(&mut app, AppInput::Char('T'), &mut store);
    assert_eq!(app.theme, Theme::Light);
    dispatch_input(&mut app, AppInput::Char('T'), &mut store);
    assert_eq!(app.theme, Theme::HighContrast);
    dispatch_input(&mut app, AppInput::Char('T'), &mut store);
    assert_eq!(app.theme, Theme::Dark);
}

// ── Smoke: confirm delete cancel ─────────────────────────────────────────────

#[test]
fn smoke_confirm_delete_cancel_esc_and_n() {
    let mut app = dashboard_app();
    let mut store = no_pw();

    dispatch_input(&mut app, AppInput::Char('d'), &mut store);
    assert_eq!(app.screen, Screen::ConfirmDelete);
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert_eq!(app.screen, Screen::Dashboard);

    dispatch_input(&mut app, AppInput::Char('d'), &mut store);
    assert_eq!(app.screen, Screen::ConfirmDelete);
    dispatch_input(&mut app, AppInput::Char('n'), &mut store);
    assert_eq!(app.screen, Screen::Dashboard);
}

// ── Smoke: move-secret cancel ─────────────────────────────────────────────────

#[test]
fn smoke_move_secret_open_and_cancel() {
    let mut app = dashboard_app();
    app.secret_keys = vec!["MY_KEY".into()];
    app.secret_cursor = 0;
    let mut store = no_pw();
    dispatch_input(&mut app, AppInput::Char('m'), &mut store);
    assert!(matches!(app.screen, Screen::MoveSecret { .. }));
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert_eq!(app.screen, Screen::Dashboard);
}

// ── Smoke: ns-bulk cancel ─────────────────────────────────────────────────────

#[test]
fn smoke_ns_bulk_copy_open_and_cancel() {
    let mut app = dashboard_app();
    app.secret_keys = vec!["prod/KEY".into()];
    app.secret_cursor = 0;
    app.collapsed_namespaces.clear();
    let mut store = no_pw();
    // Cursor 0 is the "prod" namespace header → 'c' opens NsBulk copy
    dispatch_input(&mut app, AppInput::Char('c'), &mut store);
    assert!(matches!(app.screen, Screen::NsBulk { copy: true, .. }));
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert_eq!(app.screen, Screen::Dashboard);
}

// ── Smoke: login keyboard sequence ───────────────────────────────────────────

#[test]
fn smoke_login_type_password_clear_with_esc() {
    let mut app = login_app();
    let mut store = no_pw();
    for c in "hunter2".chars() {
        dispatch_input(&mut app, AppInput::Char(c), &mut store);
    }
    assert_eq!(app.password_buf.as_str(), "hunter2");
    dispatch_input(&mut app, AppInput::Esc, &mut store);
    assert!(app.password_buf.is_empty());
    assert_eq!(app.screen, Screen::Login);
}

#[test]
fn smoke_login_tab_then_down_cycles_profiles() {
    let mut app = App::new_for_test(vec!["alpha".into(), "beta".into()], Screen::Login);
    let mut store = no_pw();
    // Default focus is Password (profiles non-empty); Tab → Profile
    dispatch_input(&mut app, AppInput::Tab, &mut store);
    assert_eq!(app.login_focus, LoginFocus::Profile);
    dispatch_input(&mut app, AppInput::Down, &mut store);
    assert_eq!(app.profile_cursor, 1);
    dispatch_input(&mut app, AppInput::Down, &mut store);
    assert_eq!(app.profile_cursor, 0); // wraps
}