passless-rs 0.17.0

FIDO2 security token emulator
Documentation
use std::path::PathBuf;
use std::process::Command;

use clap::Parser;

use passless_core::{Args, Commands, OutputFormat};

fn passless_bin() -> PathBuf {
    let mut path = std::env::current_exe()
        .unwrap()
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .to_path_buf();
    path.push("passless");
    path
}

fn run_cli(args: &[&str]) -> (String, String, i32) {
    let bin = passless_bin();
    let output = Command::new(bin)
        .args(args)
        .env("HOME", "/tmp/passless-cli-test-home")
        .output()
        .expect("failed to run passless binary");
    (
        String::from_utf8_lossy(&output.stdout).to_string(),
        String::from_utf8_lossy(&output.stderr).to_string(),
        output.status.code().unwrap_or(-1),
    )
}

#[test]
fn help_exits_zero() {
    let (stdout, _, code) = run_cli(&["--help"]);
    assert_eq!(code, 0);
    assert!(stdout.contains("FIDO2 security token emulator"));
}

#[test]
fn version_exits_zero() {
    let (stdout, _, code) = run_cli(&["--version"]);
    assert_eq!(code, 0);
    assert!(stdout.contains("passless"));
}

#[test]
fn config_print_exits_zero() {
    let (stdout, _, code) = run_cli(&["config", "print"]);
    assert_eq!(code, 0);
    assert!(stdout.contains("backend_type"));
    assert!(stdout.contains("[security]"));
    assert!(stdout.contains("[pin]"));
}

#[test]
fn config_print_contains_expected_sections() {
    let (stdout, _, code) = run_cli(&["config", "print"]);
    assert_eq!(code, 0);
    assert!(stdout.contains("backend_type"));
    assert!(stdout.contains("[security]"));
    assert!(stdout.contains("[pin]"));
    assert!(stdout.contains("[pass]"));
    assert!(stdout.contains("[local]"));
    assert!(stdout.contains("always_uv"));
    assert!(stdout.contains("min_length"));
}

#[test]
fn unknown_subcommand_fails() {
    let (_, stderr, code) = run_cli(&["nonexistent-command"]);
    assert_ne!(code, 0);
    assert!(
        stderr.contains("unrecognized") || stderr.contains("invalid") || stderr.contains("error"),
        "stderr should indicate error: {}",
        stderr
    );
}

#[test]
fn invalid_backend_type_via_cli_still_prints_config() {
    let (stdout, _, code) = run_cli(&["--backend-type", "invalid_backend", "config", "print"]);
    assert_eq!(
        code, 0,
        "config print should succeed regardless of backend type"
    );
    assert!(stdout.contains("backend_type"));
}

#[test]
fn client_subcommand_structure_parses() {
    let args = Args::try_parse_from(["passless", "client", "info"]).unwrap();
    assert!(matches!(
        args.command,
        Some(Commands::Client {
            action: passless_core::ClientAction::Info,
            ..
        })
    ));
}

#[test]
fn client_list_with_domain_filter_parses() {
    let args = Args::try_parse_from(["passless", "client", "list", "-d", "example.com"]).unwrap();
    match args.command {
        Some(Commands::Client {
            action: passless_core::ClientAction::List { rp_id },
            ..
        }) => {
            assert_eq!(rp_id.as_deref(), Some("example.com"));
        }
        _ => panic!("expected List command"),
    }
}

#[test]
fn client_output_format_json_parses() {
    let args = Args::try_parse_from(["passless", "client", "-o", "json", "info"]).unwrap();
    match args.command {
        Some(Commands::Client { output, .. }) => {
            assert_eq!(output, OutputFormat::Json);
        }
        _ => panic!("expected Client command"),
    }
}

#[test]
fn client_output_format_default_is_plain() {
    let args = Args::try_parse_from(["passless", "client", "info"]).unwrap();
    match args.command {
        Some(Commands::Client { output, .. }) => {
            assert_eq!(output, OutputFormat::Plain);
        }
        _ => panic!("expected Client command"),
    }
}

#[test]
fn client_reset_requires_double_confirm() {
    let args = Args::try_parse_from([
        "passless",
        "client",
        "reset",
        "--yes-i-really-want-to-reset-my-device",
        "--yes-i-really-want-to-reset-my-device",
    ])
    .unwrap();
    match args.command {
        Some(Commands::Client {
            action: passless_core::ClientAction::Reset { confirm },
            ..
        }) => {
            assert_eq!(confirm, 2);
        }
        _ => panic!("expected Reset command"),
    }
}

#[test]
fn client_reset_without_confirm_has_zero_count() {
    let args = Args::try_parse_from(["passless", "client", "reset"]).unwrap();
    match args.command {
        Some(Commands::Client {
            action: passless_core::ClientAction::Reset { confirm },
            ..
        }) => {
            assert_eq!(confirm, 0);
        }
        _ => panic!("expected Reset command"),
    }
}

#[test]
fn client_pin_set_parses() {
    let args = Args::try_parse_from(["passless", "client", "pin", "set", "1234"]).unwrap();
    match args.command {
        Some(Commands::Client {
            action:
                passless_core::ClientAction::Pin {
                    action: passless_core::PinAction::Set { pin },
                },
            ..
        }) => {
            assert_eq!(pin, "1234");
        }
        _ => panic!("expected Pin Set command"),
    }
}

#[test]
fn client_pin_change_parses() {
    let args =
        Args::try_parse_from(["passless", "client", "pin", "change", "1234", "5678"]).unwrap();
    match args.command {
        Some(Commands::Client {
            action:
                passless_core::ClientAction::Pin {
                    action: passless_core::PinAction::Change { old_pin, new_pin },
                },
            ..
        }) => {
            assert_eq!(old_pin, "1234");
            assert_eq!(new_pin, "5678");
        }
        _ => panic!("expected Pin Change command"),
    }
}

#[test]
fn client_device_flag_parses() {
    let args = Args::try_parse_from(["passless", "client", "-D", "0", "info"]).unwrap();
    match args.command {
        Some(Commands::Client { device, .. }) => {
            assert_eq!(device.as_deref(), Some("0"));
        }
        _ => panic!("expected Client command"),
    }
}

#[test]
fn output_format_from_str() {
    assert_eq!("json".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
    assert_eq!(
        "plain".parse::<OutputFormat>().unwrap(),
        OutputFormat::Plain
    );
    assert_eq!("JSON".parse::<OutputFormat>().unwrap(), OutputFormat::Json);
    assert!("invalid".parse::<OutputFormat>().is_err());
}

#[test]
fn error_format_cli_storage_error() {
    let err = passless_core::Error::Storage("disk full".into());
    let formatted = err.format_cli();
    assert!(formatted.contains("Storage error"));
    assert!(formatted.contains("disk full"));
}

#[test]
fn error_format_cli_config_error() {
    let err = passless_core::Error::Config("bad value".into());
    let formatted = err.format_cli();
    assert!(formatted.contains("Configuration error"));
    assert!(formatted.contains("bad value"));
}

#[test]
fn error_format_cli_cancelled() {
    let err = passless_core::Error::Cancelled;
    let formatted = err.format_cli();
    assert!(formatted.contains("cancelled"));
}

#[test]
fn no_autonomous_mode_subcommand_exists() {
    use clap::CommandFactory;
    let app = Args::command();
    let subcommands: Vec<&str> = app.get_subcommands().map(|c| c.get_name()).collect();
    assert!(
        !subcommands.contains(&"autonomous"),
        "autonomous mode should not exist as a subcommand"
    );
    assert!(
        !subcommands.contains(&"daemon"),
        "daemon mode should not exist as a subcommand"
    );
}

#[test]
fn client_rename_parses_with_both_names() {
    let args = Args::try_parse_from([
        "passless", "client", "rename", "aabbccdd", "-u", "newuser", "-n", "New Name",
    ])
    .unwrap();
    match args.command {
        Some(Commands::Client {
            action:
                passless_core::ClientAction::Rename {
                    credential_id,
                    user_name,
                    display_name,
                },
            ..
        }) => {
            assert_eq!(credential_id, "aabbccdd");
            assert_eq!(user_name.as_deref(), Some("newuser"));
            assert_eq!(display_name.as_deref(), Some("New Name"));
        }
        _ => panic!("expected Rename command"),
    }
}

#[test]
fn client_show_parses_credential_id() {
    let args = Args::try_parse_from(["passless", "client", "show", "deadbeef"]).unwrap();
    match args.command {
        Some(Commands::Client {
            action: passless_core::ClientAction::Show { credential_id },
            ..
        }) => {
            assert_eq!(credential_id, "deadbeef");
        }
        _ => panic!("expected Show command"),
    }
}