diff --git a/cli/src/commands/invite/claim.rs b/cli/src/commands/invite/claim.rs index b11804bcba5..9e3bc05cbee 100644 --- a/cli/src/commands/invite/claim.rs +++ b/cli/src/commands/invite/claim.rs @@ -106,14 +106,17 @@ pub async fn main(args: Args) -> anyhow::Result<()> { match ctx { ShamirRecoveryClaimMaybeFinalizeCtx::Offline(ctx) => { let retry = Select::new() + .default(0) .with_prompt("Unable to join server, do you want to retry ?") .items(&["yes", "no"]) .interact()?; if retry == 0 { + // yes device_ctx = ctx; continue; } else { + // no return Err(anyhow!("Server offline, try again later.")); } } @@ -142,7 +145,7 @@ async fn step0( let ctx = claimer_retrieve_info(Arc::new(config.into()), addr, None).await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -151,9 +154,10 @@ async fn step0( fn shamir_pick_recipient( ctx: &ShamirRecoveryClaimPickRecipientCtx, ) -> anyhow::Result { - let recipients = ctx.yet_to_contact_recipients(); - let human_recipients: Vec<_> = recipients.iter().map(|r| r.human_handle.clone()).collect(); + let recipients = ctx.recipients_without_a_share(); + let human_recipients: Vec<&_> = recipients.iter().map(|r| &r.human_handle).collect(); let selection = Select::new() + .default(0) .with_prompt("Choose a person to contact now") .items(&human_recipients) .interact()?; @@ -201,11 +205,14 @@ async fn step1_shamir( ctx.greeter_human_handle() ); - let mut handle = start_spinner("Waiting the greeter to start the invitation procedure".into()); + let mut handle = start_spinner(format!( + "Waiting the greeter {} to start the invitation procedure", + ctx.greeter_human_handle() + )); let ctx = ctx.do_wait_peer().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -249,6 +256,7 @@ async fn step2_shamir( let sas_codes = ctx.generate_greeter_sas_choices(3); let selected_sas = Select::new() + .default(0) .items(&sas_codes) .with_prompt("Select code provided by greeter") .interact()?; @@ -288,7 +296,7 @@ async fn step3_shamir( let ctx = ctx.do_wait_peer_trust().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -346,7 +354,7 @@ async fn step4_shamir( let ctx = ctx.do_recover_share().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -357,11 +365,11 @@ async fn step5_shamir( ) -> anyhow::Result { let device_label = Input::new().with_prompt("Enter device label").interact()?; - let mut handle = start_spinner("Waiting for greeter".into()); + let mut handle = start_spinner("Recovering device".into()); let ctx = ctx.recover_device(device_label).await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } diff --git a/cli/src/commands/invite/greet.rs b/cli/src/commands/invite/greet.rs index a1c7abc7693..c212aa3fe41 100644 --- a/cli/src/commands/invite/greet.rs +++ b/cli/src/commands/invite/greet.rs @@ -61,14 +61,8 @@ pub async fn device_greet(args: Args, client: &StartedClient) -> anyhow::Result< let ctx = step4_device(ctx).await?; step5_device(ctx).await } - InviteListItem::ShamirRecovery { - token, - claimer_user_id, - .. - } => { - let ctx = client - .start_shamir_recovery_invitation_greet(token, claimer_user_id) - .await?; + InviteListItem::ShamirRecovery { token, .. } => { + let ctx = client.start_shamir_recovery_invitation_greet(token).await?; let ctx = step1_shamir(ctx).await?; let ctx = step2_shamir(ctx).await?; @@ -97,7 +91,7 @@ async fn step0( None => return Err(anyhow::anyhow!("Invitation not found")), }; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(invitation) } @@ -132,7 +126,7 @@ async fn step1_shamir( let ctx = ctx.do_wait_peer().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -182,7 +176,7 @@ async fn step2_shamir( let ctx = ctx.do_wait_peer_trust().await?; - handle.stop_with_newline(); + handle.stop_with_symbol(GREEN_CHECKMARK); Ok(ctx) } @@ -223,6 +217,7 @@ async fn step3_shamir( ) -> anyhow::Result { let sas_codes = ctx.generate_claimer_sas_choices(3); let selected_sas = Select::new() + .default(0) .items(&sas_codes) .with_prompt("Select code provided by claimer") .interact()?; diff --git a/cli/src/commands/invite/list.rs b/cli/src/commands/invite/list.rs index eba75448ba8..22acf1fbe3d 100644 --- a/cli/src/commands/invite/list.rs +++ b/cli/src/commands/invite/list.rs @@ -13,6 +13,11 @@ crate::build_main_with_client!(main, list_invite); pub async fn list_invite(_args: Args, client: &StartedClient) -> anyhow::Result<()> { log::trace!("Listing invitations"); + { + let mut spinner = start_spinner("Poll server for new certificates".into()); + client.poll_server_for_new_certificates().await?; + spinner.stop_with_symbol(GREEN_CHECKMARK); + } let mut handle = start_spinner("Listing invitations".into()); @@ -31,7 +36,7 @@ pub async fn list_invite(_args: Args, client: &StartedClient) -> anyhow::Result< status, token, .. - } => (token, status, format!("user (email={claimer_email}")), + } => (token, status, format!("user (email={claimer_email})")), InviteListItem::Device { status, token, .. } => (token, status, "device".into()), InviteListItem::ShamirRecovery { status, diff --git a/cli/src/utils.rs b/cli/src/utils.rs index ce05684dc64..bbf6618c3c7 100644 --- a/cli/src/utils.rs +++ b/cli/src/utils.rs @@ -24,7 +24,7 @@ pub const GREEN: &str = "\x1B[92m"; pub const RED: &str = "\x1B[91m"; pub const RESET: &str = "\x1B[39m"; pub const YELLOW: &str = "\x1B[33m"; -pub const GREEN_CHECKMARK: &str = "\x1B[92m🗸\x1B[39m"; +pub const GREEN_CHECKMARK: &str = "\x1B[92m✔\x1B[39m"; pub const BULLET_CHAR: &str = "•"; pub fn format_devices( diff --git a/cli/tests/integration/invitations/mod.rs b/cli/tests/integration/invitations/mod.rs index ea86be7314a..05b363aabb5 100644 --- a/cli/tests/integration/invitations/mod.rs +++ b/cli/tests/integration/invitations/mod.rs @@ -1,4 +1,5 @@ mod cancel; mod device; mod list; +mod shared_recovery; mod user; diff --git a/cli/tests/integration/invitations/shared_recovery.rs b/cli/tests/integration/invitations/shared_recovery.rs new file mode 100644 index 00000000000..144f4ec004f --- /dev/null +++ b/cli/tests/integration/invitations/shared_recovery.rs @@ -0,0 +1,191 @@ +use std::sync::{Arc, Mutex}; + +use libparsec::{ + authenticated_cmds::v4::invite_new_shamir_recovery, get_default_config_dir, tmp_path, + AuthenticatedCmds, InvitationType, ParsecInvitationAddr, ProxyConfig, TmpPath, +}; +use rexpect::{session::PtySession, spawn}; + +use crate::{ + integration_tests::{bootstrap_cli_test, shared_recovery_create}, + testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD}, +}; + +macro_rules! match_sas_code { + ($locked:ident, $sas_code:ident) => { + $locked.read_line().unwrap(); //empty line + let first = dbg!($locked.read_line().unwrap()); + let second = dbg!($locked.read_line().unwrap()); + let third = dbg!($locked.read_line().unwrap()); + + if $sas_code == first[first.len() - 4..] { + $locked.send_line("").unwrap(); + } else if $sas_code == second[second.len() - 4..] { + $locked.send_line("j").unwrap(); + } else if $sas_code == third[third.len() - 4..] { + $locked.send_line("jj").unwrap(); + } else { + panic!("no corresponding sas code available") + } + }; +} + +#[rstest::rstest] +#[tokio::test] +async fn invite_shared_recovery_dance(tmp_path: TmpPath) { + let (_, TestOrganization { alice, bob, .. }, _) = bootstrap_cli_test(&tmp_path).await.unwrap(); + + shared_recovery_create(&alice, &bob, None); + let cmds = AuthenticatedCmds::new( + &get_default_config_dir(), + bob.clone(), + ProxyConfig::new_from_env().unwrap(), + ) + .unwrap(); + + let rep = cmds + .send(invite_new_shamir_recovery::Req { + send_email: false, + claimer_user_id: alice.user_id, + }) + .await + .unwrap(); + + let invitation_addr = match rep { + invite_new_shamir_recovery::InviteNewShamirRecoveryRep::Ok { token, .. } => { + ParsecInvitationAddr::new( + alice.organization_addr.clone(), + alice.organization_id().clone(), + InvitationType::ShamirRecovery, + token, + ) + } + rep => { + panic!("Server refused to create user invitation: {rep:?}"); + } + }; + + let token = invitation_addr.token(); + + // spawn greeter thread + let mut cmd_greeter = assert_cmd::Command::cargo_bin("parsec-cli").unwrap(); + cmd_greeter.args([ + "invite", + "greet", + "--device", + &bob.device_id.hex(), + &token.hex().to_string(), + ]); + + let program_greeter = cmd_greeter.get_program().to_str().unwrap().to_string(); + let program_greeter = cmd_greeter + .get_args() + .fold(program_greeter, |acc, s| format!("{acc} {s:?}")); + + let p_greeter = Arc::new(Mutex::new( + spawn(&dbg!(program_greeter), Some(1000)).unwrap(), + )); + + // spawn claimer thread + + let mut cmd_claimer = assert_cmd::Command::cargo_bin("parsec-cli").unwrap(); + cmd_claimer.args(["invite", "claim", invitation_addr.to_url().as_ref()]); + + let program_claimer = cmd_claimer.get_program().to_str().unwrap().to_string(); + let program_claimer = cmd_claimer + .get_args() + .fold(program_claimer, |acc, s| format!("{acc} {s:?}")); + + let p_claimer = Arc::new(Mutex::new( + spawn(&dbg!(program_claimer), Some(10_000)).unwrap(), + )); + + // retrieve greeter code + let greeter_cloned = p_greeter.clone(); + let greeter = tokio::task::spawn(async move { + let mut locked = greeter_cloned.lock().unwrap(); + + locked.exp_string("Enter password for the device:").unwrap(); + locked.send_line(DEFAULT_DEVICE_PASSWORD).unwrap(); + locked.exp_string("Waiting for claimer").unwrap(); + }); + let claimer_cloned = p_claimer.clone(); + + let claimer = tokio::task::spawn(async move { + let mut locked = claimer_cloned.lock().unwrap(); + locked + .exp_string("Choose a person to contact now:") + .unwrap(); + // down to choose bob + locked.send_line("j").unwrap(); + + locked + .exp_string("Select code provided by greeter:") + .unwrap(); + }); + greeter.await.unwrap(); + p_greeter + .lock() + .unwrap() + .exp_string("Code to provide to claimer:") + .unwrap(); + let (_, matched) = p_greeter.lock().unwrap().exp_regex("[A-Z0-9]{4}").unwrap(); + let sas_code = dbg!(matched[matched.len() - 4..].to_string()); // last 4 chars are the sas code + + // code selection + + claimer.await.unwrap(); + let cloned_claimer = p_claimer.clone(); + { + let mut locked = cloned_claimer.lock().unwrap(); + + match_sas_code!(locked, sas_code); + } + + // retrieve claimer code + let greeter_cloned = p_greeter.clone(); + let greeter = tokio::task::spawn(async move { + let mut locked = greeter_cloned.lock().unwrap(); + locked.exp_string("Waiting for claimer").unwrap(); + locked + .exp_string("Select code provided by claimer:") + .unwrap(); + }); + + let sas_code = { + let mut locked = p_claimer.lock().unwrap(); + + locked.exp_string("Code to provide to greeter:").unwrap(); + let (_, matched) = locked.exp_regex("[A-Z0-9]{4}").unwrap(); + dbg!(matched[matched.len() - 4..].to_string()) // last 4 chars are the sas code + }; + greeter.await.unwrap(); + + { + let mut locked = p_greeter.lock().unwrap(); + + match_sas_code!(locked, sas_code); + } + let mut greeter = Arc::>::try_unwrap(p_greeter) + .ok() + .unwrap() + .into_inner() + .unwrap(); + greeter.exp_eof().unwrap(); + greeter.process.exit().unwrap(); + drop(greeter); + // device creation + let mut locked = p_claimer.lock().unwrap(); + locked.exp_string("Enter device label:").unwrap(); + locked.send_line("label").unwrap(); + locked.exp_string("Recovering device").unwrap(); + + locked + .exp_string("Enter password for the new device:") + .unwrap(); + locked.send_line(DEFAULT_DEVICE_PASSWORD).unwrap(); + locked.exp_string("Confirm password:").unwrap(); + + locked.send_line(DEFAULT_DEVICE_PASSWORD).unwrap(); + locked.exp_eof().unwrap(); +} diff --git a/cli/tests/integration/mod.rs b/cli/tests/integration/mod.rs index b532f33b8e4..a611cb1c7d0 100644 --- a/cli/tests/integration/mod.rs +++ b/cli/tests/integration/mod.rs @@ -16,10 +16,15 @@ use std::{ path::{Path, PathBuf}, }; +use crate::utils::*; +use libparsec::LocalDevice; use libparsec::{ ClientConfig, OrganizationID, ParsecAddr, TmpPath, PARSEC_BASE_CONFIG_DIR, PARSEC_BASE_DATA_DIR, PARSEC_BASE_HOME_DIR, }; +use std::sync::Arc; + +use crate::testenv_utils::DEFAULT_DEVICE_PASSWORD; use crate::testenv_utils::{ initialize_test_organization, new_environment, parsec_addr_from_http_url, TestOrganization, @@ -140,3 +145,70 @@ macro_rules! assert_cmd_failure { .failure() } } + +/// For Alice, with Bob and Toto as recipients +fn shared_recovery_create( + alice: &Arc, + bob: &Arc, + toto: Option<&Arc>, +) { + crate::assert_cmd_success!( + with_password = DEFAULT_DEVICE_PASSWORD, + "shared-recovery", + "info", + "--device", + &alice.device_id.hex() + ) + .stdout(predicates::str::contains(format!( + "Shared recovery {RED}never setup{RESET}" + ))); + + if let Some(toto) = toto { + crate::assert_cmd_success!( + with_password = DEFAULT_DEVICE_PASSWORD, + "shared-recovery", + "create", + "--device", + &alice.device_id.hex(), + "--recipients", + &bob.human_handle.email(), + &toto.human_handle.email(), + "--weights", + "1", + "1", + "--threshold", + "1" + ) + .stdout(predicates::str::contains( + "Shared recovery setup has been created", + )); + } else { + crate::assert_cmd_success!( + with_password = DEFAULT_DEVICE_PASSWORD, + "shared-recovery", + "create", + "--device", + &alice.device_id.hex(), + "--recipients", + &bob.human_handle.email(), + "--weights", + "1", + "--threshold", + "1" + ) + .stdout(predicates::str::contains( + "Shared recovery setup has been created", + )); + } + + crate::assert_cmd_success!( + with_password = DEFAULT_DEVICE_PASSWORD, + "shared-recovery", + "info", + "--device", + &alice.device_id.hex() + ) + .stdout(predicates::str::contains(format!( + "Shared recovery {GREEN}set up{RESET}" + ))); +} diff --git a/cli/tests/integration/shared_recovery/delete.rs b/cli/tests/integration/shared_recovery/delete.rs index 1dbeee8d66f..72384e2cb82 100644 --- a/cli/tests/integration/shared_recovery/delete.rs +++ b/cli/tests/integration/shared_recovery/delete.rs @@ -1,6 +1,6 @@ use libparsec::{tmp_path, TmpPath}; -use crate::integration_tests::shared_recovery::shared_recovery_create; +use crate::integration_tests::shared_recovery_create; use crate::testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD}; use crate::utils::*; @@ -17,7 +17,7 @@ async fn remove_shared_recovery_ok(tmp_path: TmpPath) { _, ) = bootstrap_cli_test(&tmp_path).await.unwrap(); - shared_recovery_create(&alice, &bob, &toto); + shared_recovery_create(&alice, &bob, Some(&toto)); crate::assert_cmd_success!( with_password = DEFAULT_DEVICE_PASSWORD, diff --git a/cli/tests/integration/shared_recovery/list.rs b/cli/tests/integration/shared_recovery/list.rs index 463eb1691db..71cf0583f1f 100644 --- a/cli/tests/integration/shared_recovery/list.rs +++ b/cli/tests/integration/shared_recovery/list.rs @@ -1,6 +1,6 @@ use libparsec::{tmp_path, TmpPath}; -use crate::integration_tests::shared_recovery::shared_recovery_create; +use crate::integration_tests::shared_recovery_create; use crate::testenv_utils::{TestOrganization, DEFAULT_DEVICE_PASSWORD}; use crate::utils::*; @@ -26,7 +26,7 @@ async fn list_shared_recovery_ok(tmp_path: TmpPath) { ) .stdout(predicates::str::contains("No shared recovery found")); - shared_recovery_create(&alice, &bob, &toto); + shared_recovery_create(&alice, &bob, Some(&toto)); crate::assert_cmd_success!( with_password = DEFAULT_DEVICE_PASSWORD, diff --git a/cli/tests/integration/shared_recovery/mod.rs b/cli/tests/integration/shared_recovery/mod.rs index 82ba9b4d15e..b6ce6518194 100644 --- a/cli/tests/integration/shared_recovery/mod.rs +++ b/cli/tests/integration/shared_recovery/mod.rs @@ -1,56 +1,4 @@ -use crate::utils::*; -use libparsec::LocalDevice; -use std::sync::Arc; - -use crate::testenv_utils::DEFAULT_DEVICE_PASSWORD; - mod create; mod delete; mod info; mod list; - -fn shared_recovery_create( - alice: &Arc, - bob: &Arc, - toto: &Arc, -) { - crate::assert_cmd_success!( - with_password = DEFAULT_DEVICE_PASSWORD, - "shared-recovery", - "info", - "--device", - &alice.device_id.hex() - ) - .stdout(predicates::str::contains(format!( - "Shared recovery {RED}never setup{RESET}" - ))); - crate::assert_cmd_success!( - with_password = DEFAULT_DEVICE_PASSWORD, - "shared-recovery", - "create", - "--device", - &alice.device_id.hex(), - "--recipients", - &bob.human_handle.email(), - &toto.human_handle.email(), - "--weights", - "1", - "1", - "--threshold", - "1" - ) - .stdout(predicates::str::contains( - "Shared recovery setup has been created", - )); - - crate::assert_cmd_success!( - with_password = DEFAULT_DEVICE_PASSWORD, - "shared-recovery", - "info", - "--device", - &alice.device_id.hex() - ) - .stdout(predicates::str::contains(format!( - "Shared recovery {GREEN}set up{RESET}" - ))); -} diff --git a/libparsec/crates/client/src/invite/claimer.rs b/libparsec/crates/client/src/invite/claimer.rs index d50c5536a9e..0aba7ee0e2f 100644 --- a/libparsec/crates/client/src/invite/claimer.rs +++ b/libparsec/crates/client/src/invite/claimer.rs @@ -356,7 +356,7 @@ impl ShamirRecoveryClaimPickRecipientCtx { self.shamir_recovery_created_on } - pub fn yet_to_contact_recipients(&self) -> Vec<&ShamirRecoveryRecipient> { + pub fn recipients_without_a_share(&self) -> Vec<&ShamirRecoveryRecipient> { self.recipients .iter() .filter(|r| !self.shares.contains_key(&r.user_id))