Adds some basic test cases & infrastructure for testing pijul identity. Hopefully the testing infrastructure can be adapted with (relative) ease to support other commands, not just pijul identity.
FOCBVLOUXYA7ZCUZA2CU3JU2QGF3ZOXW6EAVL5KZINN43GXNL7CQC #![deny(clippy::all)]#![warn(clippy::pedantic)]#![warn(clippy::nursery)]#![warn(clippy::cargo)]mod common;use anyhow::Error;use common::identity::{default, prompt, Identity, SubCommand};use common::{Interaction, InteractionType, SecondAttempt};fn default_id_name() -> Interaction {Interaction::new(prompt::ID_NAME,InteractionType::Input(default::ID_NAME.to_string()),)}#[test]fn new_minimal() -> Result<(), Error> {let identity = Identity::new("new_minimal",default_id_name(),None,None,None,None,None,None,)?;identity.run(&SubCommand::New, Vec::new())?;Ok(())}#[test]fn new_full() -> Result<(), Error> {let identity = Identity::new("new_full",default_id_name(),Some(default::FULL_NAME.to_string()),Some(Interaction::new(prompt::EMAIL,InteractionType::Input(default::EMAIL.to_string()),)),Some(Interaction::new(prompt::EXPIRY_DATE,InteractionType::Input(default::EXPIRY.to_string()),)),Some(Interaction::new(prompt::LOGIN,InteractionType::Input(default::LOGIN.to_string()),)),Some(Interaction::new(prompt::ORIGIN,InteractionType::Input(default::ORIGIN.to_string()),)),Some(Interaction::new(prompt::PASSWORD,InteractionType::Password {input: default::PASSWORD.to_string(),confirm: Some(prompt::PASSWORD_REPROMPT.to_string()),},)),)?;identity.run(&SubCommand::New, Vec::new())?;Ok(())}#[test]fn new_email() -> Result<(), Error> {let identity = Identity::new("new_email",default_id_name(),None,Some(Interaction::new(prompt::EMAIL,InteractionType::Input(String::from("BAD-EMAIL")),).with_second_attempt(SecondAttempt::new(InteractionType::Input(default::EMAIL.to_string()),"Invalid email address",)?)?,),None,None,None,None,)?;identity.run(&SubCommand::New, Vec::new())?;Ok(())}#[test]fn new_expiry() -> Result<(), Error> {let identity = Identity::new("new_expiry",default_id_name(),None,None,Some(Interaction::new(prompt::EXPIRY_DATE,InteractionType::Input(String::from("BAD-EXPIRY")),).with_second_attempt(SecondAttempt::new(InteractionType::Input(default::EXPIRY.to_string()),"Invalid date",)?)?,),None,None,None,)?;identity.run(&SubCommand::New, Vec::new())?;Ok(())}#[test]fn new_login() -> Result<(), Error> {let identity = Identity::new("new_login",default_id_name(),None,None,None,Some(Interaction::new(prompt::LOGIN,InteractionType::Input(default::LOGIN.to_string()),)),None,None,)?;identity.run(&SubCommand::New, Vec::new())?;Ok(())}#[test]fn new_origin() -> Result<(), Error> {let identity = Identity::new("new_origin",default_id_name(),None,None,None,None,Some(Interaction::new(prompt::ORIGIN,InteractionType::Input(default::ORIGIN.to_string()),)),None,)?;identity.run(&SubCommand::New, Vec::new())?;Ok(())}#[test]fn new_password() -> Result<(), Error> {let identity = Identity::new("new_password",default_id_name(),None,None,None,None,None,Some(Interaction::new(prompt::PASSWORD,InteractionType::Password {input: default::PASSWORD.to_string(),confirm: Some(prompt::PASSWORD_REPROMPT.to_string()),},).with_second_attempt(SecondAttempt::new(InteractionType::Password {input: "Good-Password".to_string(),confirm: Some(prompt::PASSWORD_REPROMPT.to_string()),},"Password mismatch",)?)?,),)?;identity.run(&SubCommand::New, Vec::new())?;Ok(())}#[test]fn edit_full() -> Result<(), Error> {let old_identity = Identity::new("edit_full",default_id_name(),None,None,None,None,None,None,)?;let new_identity = Identity::new("edit_full",Interaction::new(prompt::ID_NAME,InteractionType::Input(String::from("new_id_name")),),Some(default::FULL_NAME.to_string()),Some(Interaction::new(prompt::EMAIL,InteractionType::Input(String::from("BAD_EMAIL")),).with_second_attempt(SecondAttempt::new(InteractionType::Input(default::EMAIL.to_string()),"Invalid email address",)?)?,),Some(Interaction::new(prompt::EXPIRY_DATE,InteractionType::Input(String::from("BAD-EXPIRY")),).with_second_attempt(SecondAttempt::new(InteractionType::Input(default::EXPIRY.to_string()),"Invalid date",)?)?,),Some(Interaction::new(prompt::LOGIN,InteractionType::Input(default::LOGIN.to_string()),)),Some(Interaction::new(prompt::ORIGIN,InteractionType::Input(default::ORIGIN.to_string()),)),Some(Interaction::new(prompt::PASSWORD,InteractionType::Password {input: default::PASSWORD.to_string(),confirm: Some(prompt::PASSWORD_REPROMPT.to_string()),},).with_second_attempt(SecondAttempt::new(InteractionType::Password {input: "Good-Password".to_string(),confirm: Some(prompt::PASSWORD_REPROMPT.to_string()),},"Password mismatch",)?)?,),)?;new_identity.run(&SubCommand::Edit(old_identity.id_name.valid_input().as_string()),vec![old_identity],)?;Ok(())}#[test]fn edit_id_name() -> Result<(), Error> {let old_identity = Identity::new("edit_id_name",default_id_name(),None,None,None,None,None,None,)?;let new_identity = Identity::new("edit_id_name",Interaction::new(prompt::ID_NAME,InteractionType::Input(String::from("new_id_name")),),None,None,None,None,None,None,)?;new_identity.run(&SubCommand::Edit(old_identity.id_name.valid_input().as_string()),vec![old_identity],)?;Ok(())}#[test]fn edit_email() -> Result<(), Error> {let old_identity = Identity::new("edit_email",default_id_name(),None,None,None,None,None,None,)?;let new_identity = Identity::new("edit_email",default_id_name(),None,Some(Interaction::new(prompt::EMAIL,InteractionType::Input(String::from("BAD_EMAIL")),).with_second_attempt(SecondAttempt::new(InteractionType::Input(default::EMAIL.to_string()),"Invalid email address",)?)?,),None,None,None,None,)?;new_identity.run(&SubCommand::Edit(old_identity.id_name.valid_input().as_string()),vec![old_identity],)?;Ok(())}#[test]fn edit_expiry() -> Result<(), Error> {let old_identity = Identity::new("edit_expiry",default_id_name(),None,None,None,None,None,None,)?;let new_identity = Identity::new("edit_expiry",default_id_name(),None,None,Some(Interaction::new(prompt::EXPIRY_DATE,InteractionType::Input(String::from("BAD-EXPIRY")),).with_second_attempt(SecondAttempt::new(InteractionType::Input(default::EXPIRY.to_string()),"Invalid date",)?)?,),None,None,None,)?;new_identity.run(&SubCommand::Edit(old_identity.id_name.valid_input().as_string()),vec![old_identity],)?;Ok(())}#[test]fn edit_password() -> Result<(), Error> {let old_identity = Identity::new("edit_password",default_id_name(),None,None,None,None,None,None,)?;let new_identity = Identity::new("edit_password",default_id_name(),None,None,None,None,None,Some(Interaction::new(prompt::PASSWORD,InteractionType::Password {input: default::PASSWORD.to_string(),confirm: Some(prompt::PASSWORD_REPROMPT.to_string()),},).with_second_attempt(SecondAttempt::new(InteractionType::Password {input: "Good-Password".to_string(),confirm: Some(prompt::PASSWORD_REPROMPT.to_string()),},"Password mismatch",)?)?,),)?;new_identity.run(&SubCommand::Edit(old_identity.id_name.valid_input().as_string()),vec![old_identity],)?;Ok(())}#[test]fn remove() -> Result<(), Error> {let identity = Identity::new("new_minimal",default_id_name(),None,None,None,None,None,None,)?;identity.run(&SubCommand::Remove, vec![identity.clone()])?;Ok(())}
#![deny(clippy::all)]#![warn(clippy::pedantic)]#![warn(clippy::nursery)]#![warn(clippy::cargo)]pub mod identity;use std::io::{Read, Write};use anyhow::{bail, Error};use expectrl::{process::{unix::UnixProcess, NonBlocking},ControlCode, Regex, Session,};#[derive(Clone, Debug)]pub enum InteractionType {Confirm(bool),Input(String),Password {input: String,confirm: Option<String>,},}impl InteractionType {pub fn as_string(&self) -> String {match self {Self::Confirm(confirm) => {if *confirm {String::from('y')} else {String::from('n')}}Self::Input(input) | Self::Password { input, .. } => input.clone(),}}}#[derive(Clone, Debug)]pub struct SecondAttempt {input: InteractionType,error_message: String,}impl SecondAttempt {pub fn new<S: Into<String>>(input: InteractionType, error_msg: S) -> Result<Self, Error> {let error_message: String = error_msg.into();if matches!(input, InteractionType::Confirm(_)) && !error_message.is_empty() {bail!("Cannot have error message for confirm propmt");}Ok(Self {input,error_message,})}}#[derive(Clone, Debug)]pub struct Interaction {prompt_message: String,input: InteractionType,second_attempt: Option<SecondAttempt>,}impl Interaction {pub fn new<S: Into<String>>(prompt_message: S, input: InteractionType) -> Self {Self {prompt_message: prompt_message.into(),input,second_attempt: None,}}pub fn with_second_attempt(mut self, second_attempt: SecondAttempt) -> Result<Self, Error> {if let Some(second_attempt) = self.second_attempt.clone() {let interaction_type = second_attempt.input;if !matches!(&self.input, interaction_type) {bail!("Cannot have non-matching second input!");}}self.second_attempt = Some(second_attempt);Ok(self)}pub fn get_input(&self, valid: bool) -> String {if let Some(invalid) = self.invalid_input() && !valid {invalid.as_string()} else {self.valid_input().as_string()}}pub fn invalid_input(&self) -> Option<InteractionType> {if self.second_attempt.is_some() {Some(self.input.clone())} else {None}}pub fn valid_input(&self) -> InteractionType {if let Some(second_input) = &self.second_attempt {second_input.input.clone()} else {self.input.clone()}}pub fn interact<S: NonBlocking + Write + Read>(&self,session: &mut Session<UnixProcess, S>,) -> Result<(), Error> {// Wait for the text to come inprintln!("Expecting prompt message: {}", self.prompt_message);session.expect(&self.prompt_message)?;match &self.input {InteractionType::Confirm(confirm) => {println!("Sending confirmation: {confirm}");session.send(&self.input.as_string())?;}InteractionType::Input(_) => {if let Some(invalid_input) = self.invalid_input() {clear_prompt(session)?;println!("Sending invalid input: {}", invalid_input.as_string());session.send(invalid_input.as_string())?;session.send_control(ControlCode::CarriageReturn)?;let error_message = self.second_attempt.clone().unwrap().error_message;println!("Expecting error message: {error_message}");session.expect(error_message)?;}clear_prompt(session)?;let valid_input = self.valid_input().as_string();println!("Sending valid input: {}", valid_input);session.send(valid_input)?;session.send_control(ControlCode::CarriageReturn)?;}InteractionType::Password { confirm, .. } => {let valid_password = self.valid_input().as_string();println!("Sending valid password: {valid_password}");session.send(&valid_password)?;session.send_control(ControlCode::CarriageReturn)?;// If there is a second attempt, send the invalid passwordif let Some(second_attempt) = self.invalid_input() {let confirm_prompt = confirm.as_ref().unwrap();println!("Expecting password re-prompt: {confirm_prompt}");session.expect(confirm_prompt)?;let invalid_password = second_attempt.as_string();println!("Sending invalid password: {invalid_password}");session.send(&invalid_password)?;session.send_control(ControlCode::CarriageReturn)?;let error_message = self.second_attempt.clone().unwrap().error_message;println!("Expecting error message: {error_message}");session.expect(&error_message)?;}// Sometimes the password needs to be confirmedif let Some(confirm_prompt) = confirm {// In the case of invalid input, we have to send twiceif self.invalid_input().is_some() {println!("Expecting prompt message: {}", self.prompt_message);session.expect(&self.prompt_message)?;println!("Sending valid password: {valid_password}");session.send(&valid_password)?;session.send_control(ControlCode::CarriageReturn)?;}println!("Expecting password re-prompt: {confirm_prompt}");session.expect(confirm_prompt)?;println!("Re-sending valid password: {valid_password}");session.send(&valid_password)?;session.send_control(ControlCode::CarriageReturn)?;}}}Ok(())}}fn clear_prompt<S: NonBlocking + Write + Read>(session: &mut Session<UnixProcess, S>,) -> Result<(), Error> {println!("Clearing prompt");// Use regex to find where the prompt endslet prompt_regex = r":.*";let captures = session.expect(Regex(prompt_regex))?;let matches = captures.matches();// Clear default text by sending backspacesfor _ in 0..matches.last().unwrap().len() {session.send_control(ControlCode::Backspace)?;}Ok(())}
use std::{ffi::OsStr,io::Read,path::{Path, PathBuf},process::Command,};use anyhow::Error;use expectrl::{Session, WaitStatus};use super::{Interaction, InteractionType};pub mod default {pub const ID_NAME: &str = "my_identity";pub const FULL_NAME: &str = "Firstname Lastname";pub const EMAIL: &str = "person@example.com";pub const EXPIRY: &str = "2056-01-01";pub const LOGIN: &str = "my_username";pub const ORIGIN: &str = "ssh.pijul.com";pub const PASSWORD: &str = "correct-horse-battery-staple";}pub mod prompt {pub const ID_NAME: &str = "Unique identity name";pub const FULL_NAME: &str = "Display name";pub const EMAIL: &str = "Email (leave blank for none)";pub const EXPIRY_DATE: &str = "Expiry date (YYYY-MM-DD)";pub const LOGIN: &str = "Remote username";pub const ORIGIN: &str = "Remote URL";pub const PASSWORD: &str = "Secret key password";pub const PASSWORD_REPROMPT: &str = "Confirm password";pub mod confirm {pub const EXPIRY: &str = "Do you want this key to expire?";pub const REMOTE: &str = "Do you want to link this identity to a remote?";pub const ENCRYPTION: &str = "Do you want to change the encryption?";}}const CONFIG_DATA: &str = "colors = 'never'[author]login = ''";#[derive(Clone)]pub enum SubCommand {New,Edit(String),Remove,}#[derive(Clone)]pub struct Identity {pub id_name: Interaction,pub full_name: Option<String>,pub email: Option<Interaction>,pub expiry: Option<Interaction>,pub login: Option<Interaction>,pub origin: Option<Interaction>,pub password: Option<Interaction>,config_path: PathBuf,}impl Identity {pub fn new<P: AsRef<Path>>(path_name: P,id_name: Interaction,full_name: Option<String>,email: Option<Interaction>,expiry: Option<Interaction>,login: Option<Interaction>,remote: Option<Interaction>,password: Option<Interaction>,) -> Result<Self, Error> {let config_path = std::path::PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join(path_name);let identity = Self {id_name,full_name,email,expiry,login,origin: remote,password,config_path,};identity.reset_fs(Vec::new().as_slice())?;Ok(identity)}pub fn reset_fs(&self, existing_identities: &[Identity]) -> Result<(), Error> {let mut config_path = self.config_path.clone();config_path.push("identities");if config_path.exists() {std::fs::remove_dir_all(&config_path)?;}std::fs::create_dir_all(&config_path)?;config_path.pop();config_path.push("config.toml");std::fs::write(&config_path, CONFIG_DATA)?;config_path.pop();// Create every identity that should existfor existing_id in existing_identities {assert_eq!(existing_id.config_path, config_path);println!("Creating existing identity with name: {}",existing_id.id_name.valid_input().as_string());existing_id.run_cli_edit(generate_command(&config_path, &SubCommand::New),true,&SubCommand::New,)?;}Ok(())}fn verify(&self) -> Result<(), Error> {let identity_path = self.config_path.join("identities").join(&self.id_name.valid_input().as_string()).join("identity.toml");// Parse the generated TOML and verifylet identity_data = std::fs::read_to_string(identity_path)?;let toml_data = identity_data.parse::<toml::Value>().unwrap();self.full_name.as_ref().map_or_else(|| {if let Some(full_name) = toml_data.get("name") {assert_eq!(full_name.as_str().unwrap(), whoami::realname());}},|full_name| {assert_eq!(full_name.as_str(),toml_data.get("name").unwrap().as_str().unwrap());},);self.email.as_ref().map_or_else(|| {assert!(toml_data.get("email").is_none());},|email| {assert_eq!(email.valid_input().as_string().as_str(),toml_data.get("email").unwrap().as_str().unwrap());},);self.login.as_ref().map_or_else(|| {let default = toml::value::Value::String(String::new());let data = toml_data.get("login").unwrap_or(&default).as_str().unwrap();assert!(data.is_empty() || data == whoami::username());},|login| {assert_eq!(login.valid_input().as_string().as_str(),toml_data.get("login").unwrap().as_str().unwrap());},);self.origin.as_ref().map_or_else(|| {let default = toml::value::Value::String(String::new());let data = toml_data.get("origin").unwrap_or(&default).as_str().unwrap();assert!(data.is_empty() || data == "ssh.pijul.com");},|origin| {assert_eq!(origin.valid_input().as_string().as_str(),toml_data.get("origin").unwrap().as_str().unwrap());},);if let Some(expiry) = &self.expiry {let time_stamp = toml_data.get("public_key").unwrap().get("expires").unwrap().as_str().unwrap();let parsed_time_stamp =dateparser::parse_with_timezone(time_stamp, &chrono::offset::Utc)?;assert_eq!(expiry.valid_input().as_string(),parsed_time_stamp.format("%Y-%m-%d").to_string());} else {assert!(toml_data.get("public_key").unwrap().get("expires").is_none());}let mut secret_key_file = std::fs::File::open(self.config_path.join("identities").join(self.id_name.valid_input().as_string()).join("secret_key.json"),)?;let mut secret_key_text = String::new();secret_key_file.read_to_string(&mut secret_key_text)?;let secret_key: libpijul::key::SecretKey = serde_json::from_str(&secret_key_text)?;assert_eq!(secret_key.encryption.is_some(), self.password.is_some());self.password.as_ref().map_or_else(|| {secret_key.load(None).unwrap();},|password| {secret_key.load(Some(password.valid_input().as_string().as_str())).unwrap();},);Ok(())}pub fn run_cli_edit(&self,mut pijul_cmd: Command,valid: bool,subcmd: &SubCommand,) -> Result<WaitStatus, Error> {pijul_cmd.arg("--no-prompt").arg("--name");match subcmd {SubCommand::New => {pijul_cmd.arg(self.id_name.get_input(valid));}SubCommand::Edit(old_name) => {pijul_cmd.arg(&old_name).arg("--new-name").arg(self.id_name.get_input(valid));}SubCommand::Remove => {panic!("Wrong function call!");}};if let Some(full_name) = self.full_name.clone() {pijul_cmd.arg("--display-name").arg(full_name);}if let Some(email) = self.email.clone() {pijul_cmd.arg("--email").arg(email.get_input(valid));}if let Some(expiry) = self.expiry.clone() {pijul_cmd.arg("--expiry").arg(expiry.get_input(valid));}if let Some(login) = self.login.clone() {pijul_cmd.arg("--username").arg(login.get_input(valid));}if let Some(origin) = self.origin.clone() {pijul_cmd.arg("--remote").arg(origin.get_input(valid));}if self.password.is_some() {pijul_cmd.arg("--read-password");}println!("Running pijul with args: {:#?}",pijul_cmd.get_args().collect::<Vec<_>>().join(OsStr::new(" ")));let mut session = Session::spawn(pijul_cmd)?;if let Some(password) = self.password.clone() && valid {password.interact(&mut session)?;}Ok(session.wait()?)}fn run_interactive_edit(&self, pijul_cmd: Command) -> Result<WaitStatus, Error> {let mut session = Session::spawn(pijul_cmd)?;self.id_name.interact(&mut session)?;if let Some(full_name) = self.full_name.clone() {Interaction::new(prompt::FULL_NAME, InteractionType::Input(full_name)).interact(&mut session)?;} else {Interaction::new(prompt::FULL_NAME, InteractionType::Input(String::new())).interact(&mut session)?;}self.email.clone().unwrap_or(Interaction::new(prompt::EMAIL,InteractionType::Input(String::new()),)).interact(&mut session)?;Interaction::new(format!("{} (Current expiry: never)", prompt::confirm::EXPIRY),InteractionType::Confirm(self.expiry.is_some()),).interact(&mut session)?;if let Some(expiry) = self.expiry.clone() {expiry.interact(&mut session)?;}let remote_data = self.login.is_some() || self.origin.is_some();Interaction::new(prompt::confirm::REMOTE,InteractionType::Confirm(remote_data),).interact(&mut session)?;if remote_data {if let Some(login) = self.login.clone() {login.interact(&mut session)?;} else {// Use an empty loginInteraction::new(prompt::LOGIN, InteractionType::Input(String::new())).interact(&mut session)?;}if let Some(origin) = self.origin.clone() {origin.interact(&mut session)?;} else {// Use an empty originInteraction::new(prompt::ORIGIN, InteractionType::Input(String::new())).interact(&mut session)?;}}Interaction::new(format!("{} (Current status: not encrypted)",prompt::confirm::ENCRYPTION),InteractionType::Confirm(self.password.is_some()),).interact(&mut session)?;if let Some(password) = self.password.clone() {password.interact(&mut session)?;}Ok(session.wait()?)}pub fn run_edit(&self,subcmd: &SubCommand,existing_identities: Vec<Self>,) -> Result<(), Error> {let invalid_interactions = [self.id_name.invalid_input(),self.email.as_ref().and_then(Interaction::invalid_input),self.expiry.as_ref().and_then(Interaction::invalid_input),];// If any of the items have invalid values, we need to test the program correctly errors outif invalid_interactions.iter().any(Option::is_some) {println!("Detected invalid inputs, expecting failure with --no-prompt");self.reset_fs(&existing_identities)?;let cli_status =self.run_cli_edit(generate_command(&self.config_path, subcmd), false, subcmd)?;assert!(!matches!(cli_status, WaitStatus::Exited(_, exitcode::OK)));println!("Program failed as expected");}self.reset_fs(&existing_identities)?;let cli_status =self.run_cli_edit(generate_command(&self.config_path, subcmd), true, subcmd)?;assert!(matches!(cli_status, WaitStatus::Exited(_, exitcode::OK)));self.verify()?;println!("Successfully ran pijul in CLI mode");self.reset_fs(&existing_identities)?;let interactive_status =self.run_interactive_edit(generate_command(&self.config_path, subcmd))?;assert!(matches!(interactive_status,WaitStatus::Exited(_, exitcode::OK)));self.verify()?;println!("Successfully ran pijul in interactive mode");Ok(())}pub fn run(&self, subcmd: &SubCommand, existing_identities: Vec<Self>) -> Result<(), Error> {match subcmd {SubCommand::New | SubCommand::Edit(_) => {self.run_edit(subcmd, existing_identities)?;}SubCommand::Remove => {self.reset_fs(&existing_identities)?;let pijul_cmd = generate_command(&self.config_path, subcmd);println!("Running pijul with args: {:#?}",pijul_cmd.get_args().collect::<Vec<_>>().join(OsStr::new(" ")));let mut session = Session::spawn(pijul_cmd)?;Interaction::new("Do you wish to continue?", InteractionType::Confirm(true)).interact(&mut session)?;let status = session.wait()?;assert!(matches!(status, WaitStatus::Exited(_, exitcode::OK)));assert!(!self.config_path.join("identities").join(&self.id_name.valid_input().as_string()).exists());}}Ok(())}}fn subcommand_name(subcmd: &SubCommand) -> String {match subcmd {SubCommand::New => String::from("new"),SubCommand::Edit(_) => String::from("edit"),SubCommand::Remove => String::from("remove"),}}fn generate_command(config_path: &PathBuf, subcmd: &SubCommand) -> Command {let mut pijul_cmd = Command::new(env!("CARGO_BIN_EXE_pijul"));pijul_cmd.env("PIJUL_CONFIG_DIR", config_path);pijul_cmd.arg("identity");let subcommand = subcommand_name(&subcmd);pijul_cmd.arg(&subcommand);if subcommand == "edit" || subcommand == "new" {pijul_cmd.arg("--no-link");}pijul_cmd}