From a82546b70303000b4fc053a1ee21d3d8c7d6ad66 Mon Sep 17 00:00:00 2001 From: DanConwayDev Date: Fri, 28 Jun 2024 15:16:43 +0100 Subject: feat(login): login with nip46 remote signer and save details in git config --- Cargo.lock | 1 + Cargo.toml | 1 + src/client.rs | 31 +++- src/git.rs | 93 ++++++++++-- src/login.rs | 374 ++++++++++++++++++++++++++++++++++++++-------- src/main.rs | 7 + src/repo_ref.rs | 14 +- src/sub_commands/init.rs | 2 + src/sub_commands/login.rs | 22 ++- src/sub_commands/push.rs | 2 + src/sub_commands/send.rs | 12 +- test_utils/src/lib.rs | 5 +- tests/login.rs | 314 ++++++++++++++++++++++++-------------- 13 files changed, 674 insertions(+), 204 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 019e051..a5a8fa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1761,6 +1761,7 @@ dependencies = [ "nostr", "nostr-database", "nostr-sdk", + "nostr-signer", "nostr-sqlite", "once_cell", "passwords", diff --git a/Cargo.toml b/Cargo.toml index e25fd51..d41d870 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ keyring = "2.0.5" nostr = "0.32.0" nostr-database = "0.32.0" nostr-sdk = "0.32.0" +nostr-signer = "0.32.0" nostr-sqlite = "0.32.0" passwords = "3.1.13" scrypt = "0.11.0" diff --git a/src/client.rs b/src/client.rs index 9dba528..44abb29 100644 --- a/src/client.rs +++ b/src/client.rs @@ -19,7 +19,7 @@ use indicatif::{MultiProgress, ProgressBar, ProgressState, ProgressStyle}; #[cfg(test)] use mockall::*; use nostr::Event; -use nostr_sdk::NostrSigner; +use nostr_sdk::{EventBuilder, NostrSigner}; #[allow(clippy::struct_field_names)] pub struct Client { @@ -292,3 +292,32 @@ fn get_dedup_events(relay_results: Vec>>) -> Vec } dedup_events } + +pub async fn sign_event(event_builder: EventBuilder, signer: &NostrSigner) -> Result { + if signer.r#type().eq(&nostr_signer::NostrSignerType::NIP46) { + let term = console::Term::stderr(); + term.write_line("signing event with remote signer...")?; + let event = signer + .sign_event_builder(event_builder) + .await + .context("failed to sign event")?; + term.clear_last_lines(1)?; + Ok(event) + } else { + signer + .sign_event_builder(event_builder) + .await + .context("failed to sign event") + } +} + +pub async fn fetch_public_key(signer: &NostrSigner) -> Result { + let term = console::Term::stderr(); + term.write_line("fetching npub from remote signer...")?; + let public_key = signer + .public_key() + .await + .context("failed to get npub from remote signer")?; + term.clear_last_lines(1)?; + Ok(public_key) +} diff --git a/src/git.rs b/src/git.rs index 46687ae..bb943a9 100644 --- a/src/git.rs +++ b/src/git.rs @@ -76,8 +76,9 @@ pub trait RepoActions { ) -> Result>; fn parse_starting_commits(&self, starting_commits: &str) -> Result>; fn ancestor_of(&self, decendant: &Sha1Hash, ancestor: &Sha1Hash) -> Result; - fn get_git_config_item(&self, item: &str, global: bool) -> Result>; + fn get_git_config_item(&self, item: &str, global: Option) -> Result>; fn save_git_config_item(&self, item: &str, value: &str, global: bool) -> Result<()>; + fn remove_git_config_item(&self, item: &str, global: bool) -> Result; } impl RepoActions for Repo { @@ -581,8 +582,15 @@ impl RepoActions for Repo { } } - fn get_git_config_item(&self, item: &str, global: bool) -> Result> { - match if global { + /// setting global to None will suppliment local config with global items + /// not in local + fn get_git_config_item(&self, item: &str, global: Option) -> Result> { + let just_global = if let Some(just_global) = global { + just_global + } else { + false + }; + match if just_global { self.git_repo .config() .context("cannot open git config")? @@ -593,11 +601,22 @@ impl RepoActions for Repo { } .get_entry(item) { - Ok(item) => Ok(Some( - item.value() - .context("cannot find git config item")? - .to_string(), - )), + Ok(item) => { + if let Some(global) = global { + if item.level().eq(&git2::ConfigLevel::Local) { + if global { + bail!("only local repository login available") + } + } else if !global { + bail!("only global repository login available") + } + } + Ok(Some( + item.value() + .context("cannot find git config item")? + .to_string(), + )) + } Err(_) => Ok(None), } } @@ -613,9 +632,33 @@ impl RepoActions for Repo { self.git_repo.config().context("cannot open git config")? } .set_str(item, value) - .context("cannot set git config value")?; + .context(format!( + "cannot set {} git config item {}", + if global { "global" } else { "local" }, + item + ))?; Ok(()) } + + /// returns false if item doesn't exist + fn remove_git_config_item(&self, item: &str, global: bool) -> Result { + if self.get_git_config_item(item, Some(global))?.is_none() { + Ok(false) + } else { + if global { + self.git_repo + .config() + .context("cannot open git config")? + .open_global() + .context("cannot open global git config")? + } else { + self.git_repo.config().context("cannot open git config")? + } + .remove(item) + .context("cannot remove existing git config item")?; + Ok(true) + } + } } fn oid_to_u8_20_bytes(oid: &Oid) -> [u8; 20] { @@ -849,7 +892,9 @@ mod tests { let git_repo = Repo::from_path(&test_repo.dir)?; git_repo.save_git_config_item("test.item", "testvalue", false)?; assert_eq!( - git_repo.get_git_config_item("test.item", false)?.unwrap(), + git_repo + .get_git_config_item("test.item", Some(false))? + .unwrap(), "testvalue", ); Ok(()) @@ -859,7 +904,10 @@ mod tests { fn get_git_config_item_returns_none_if_not_present() -> Result<()> { let test_repo = GitTestRepo::default(); let git_repo = Repo::from_path(&test_repo.dir)?; - assert_eq!(git_repo.get_git_config_item("test.item", false)?, None); + assert_eq!( + git_repo.get_git_config_item("test.item", Some(false))?, + None + ); Ok(()) } @@ -869,11 +917,32 @@ mod tests { let git_repo = Repo::from_path(&test_repo.dir)?; git_repo.save_git_config_item("test.item", "", false)?; assert_eq!( - git_repo.get_git_config_item("test.item", false)?, + git_repo.get_git_config_item("test.item", Some(false))?, Some("".to_string()), ); Ok(()) } + + #[test] + fn remove_local_git_config_item() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + git_repo.save_git_config_item("test.item", "testvalue", false)?; + assert!(git_repo.remove_git_config_item("test.item", false)?); + assert_eq!( + git_repo.get_git_config_item("test.item", Some(false))?, + None, + ); + Ok(()) + } + + #[test] + fn remove_git_config_item_returns_false_if_item_wasnt_set() -> Result<()> { + let test_repo = GitTestRepo::default(); + let git_repo = Repo::from_path(&test_repo.dir)?; + assert!(!(git_repo.remove_git_config_item("test.item", false)?)); + Ok(()) + } } #[test] diff --git a/src/login.rs b/src/login.rs index e1669c1..218a079 100644 --- a/src/login.rs +++ b/src/login.rs @@ -1,11 +1,13 @@ -use std::str::FromStr; +use std::{fs::create_dir_all, str::FromStr, time::Duration}; use anyhow::{bail, Context, Result}; -use nostr::PublicKey; +use nostr::{nips::nip46::NostrConnectURI, PublicKey}; use nostr_database::Order; use nostr_sdk::{ - Alphabet, FromBech32, JsonUtil, Kind, NostrDatabase, NostrSigner, SingleLetterTag, ToBech32, + Alphabet, FromBech32, JsonUtil, Keys, Kind, NostrDatabase, NostrSigner, SingleLetterTag, + ToBech32, }; +use nostr_signer::Nip46Signer; use nostr_sqlite::SQLiteDatabase; #[cfg(not(test))] @@ -16,7 +18,7 @@ use crate::{ cli_interactor::{ Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms, }, - client::Connect, + client::{fetch_public_key, Connect}, config::{get_dirs, UserMetadata, UserRef, UserRelayRef, UserRelays}, git::{Repo, RepoActions}, key_handling::encryption::{decrypt_key, encrypt_key}, @@ -25,14 +27,25 @@ use crate::{ /// handles the encrpytion and storage of key material pub async fn launch( git_repo: &Repo, + bunker_uri: &Option, + bunker_app_key: &Option, nsec: &Option, password: &Option, #[cfg(test)] client: Option<&MockConnect>, #[cfg(not(test))] client: Option<&Client>, change_user: bool, ) -> Result<(NostrSigner, UserRef)> { - if let Ok(keys) = match get_keys_without_prompts(git_repo, nsec, password, change_user) { - Ok(keys) => Ok(keys), + if let Ok(signer) = match get_signer_without_prompts( + git_repo, + bunker_uri, + bunker_app_key, + nsec, + password, + change_user, + ) + .await + { + Ok(signer) => Ok(signer), Err(error) => { if error .to_string() @@ -60,7 +73,7 @@ pub async fn launch( .password(PromptPasswordParms::default().with_prompt("password")) .context("failed to get password input from interactor.password")?; if let Ok(keys) = get_keys_with_password(git_repo, &password) { - break Ok(keys); + break Ok(NostrSigner::Keys(keys)); } println!("incorrect password"); } @@ -73,9 +86,17 @@ pub async fn launch( } } { // get user ref - let user_ref = get_user_details(&keys.public_key(), client, git_repo).await?; + let user_ref = get_user_details( + &signer + .public_key() + .await + .context("cannot get public key from signer")?, + client, + git_repo, + ) + .await?; print_logged_in_as(&user_ref, client.is_none())?; - Ok((NostrSigner::Keys(keys), user_ref)) + Ok((signer, user_ref)) } else { fresh_login(git_repo, client, change_user).await } @@ -95,18 +116,45 @@ fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { Ok(()) } -fn get_keys_without_prompts( +async fn get_signer_without_prompts( git_repo: &Repo, + bunker_uri: &Option, + bunker_app_key: &Option, nsec: &Option, password: &Option, save_local: bool, -) -> Result { +) -> Result { if let Some(nsec) = nsec { - get_keys_from_nsec(git_repo, nsec, password, save_local) + Ok(NostrSigner::Keys(get_keys_from_nsec( + git_repo, nsec, password, save_local, + )?)) } else if let Some(password) = password { - get_keys_with_password(git_repo, password) + Ok(NostrSigner::Keys(get_keys_with_password( + git_repo, password, + )?)) + } else if let Some(bunker_uri) = bunker_uri { + if let Some(bunker_app_key) = bunker_app_key { + let signer = get_nip46_signer_from_uri_and_key(bunker_uri, bunker_app_key) + .await + .context("failed to connect with remote signer")?; + if save_local { + save_to_git_config( + git_repo, + &signer.public_key().await?.to_bech32()?, + &None, + &Some((bunker_uri.to_string(),bunker_app_key.to_string())), + false, + ) + .context("failed to save bunker details local git config nostr.bunker-uri and nostr.bunker-app-key")?; + } + Ok(signer) + } else { + bail!( + "bunker-app-key parameter must be provided alongside bunker-uri. if unknown, login interactively." + ) + } } else if !save_local { - get_keys_with_git_config_nsec_without_prompts(git_repo) + get_signer_with_git_config_nsec_or_bunker_without_prompts(git_repo).await } else { bail!("user wants prompts to specify new keys") } @@ -139,18 +187,82 @@ fn get_keys_from_nsec( if let Some(password) = password { s = encrypt_key(&keys, password)?; } - git_repo - .save_git_config_item("nostr.nsec", &s, false) - .context("failed to save encrypted nsec in local git config nostr.nsec")?; - git_repo.save_git_config_item("nostr.npub", &keys.public_key().to_bech32()?, false)?; + save_to_git_config( + git_repo, + &keys.public_key().to_bech32()?, + &Some(s), + &None, + false, + ) + .context("failed to save encrypted nsec in local git config nostr.nsec")?; } Ok(keys) } +fn save_to_git_config( + git_repo: &Repo, + npub: &str, + nsec: &Option, + bunker: &Option<(String, String)>, + global: bool, +) -> Result<()> { + if let Err(error) = silently_save_to_git_config(git_repo, npub, nsec, bunker, global) { + println!( + "failed to save login details to {} git config", + if global { "global" } else { "local" } + ); + if let Some(nsec) = nsec { + if nsec.contains("ncryptsec") { + println!("manually set git config nostr.nsec to: {nsec}"); + } else { + println!("manually set git config nostr.nsec"); + } + } + if let Some(bunker) = bunker { + println!("manually set git config as follows:"); + println!("nostr.bunker-uri: {}", bunker.0); + println!("nostr.bunker-app-key: {}", bunker.1); + } + Err(error) + } else { + println!( + "saved login details to {} git config", + if global { "global" } else { "local" } + ); + Ok(()) + } +} +fn silently_save_to_git_config( + git_repo: &Repo, + npub: &str, + nsec: &Option, + bunker: &Option<(String, String)>, + global: bool, +) -> Result<()> { + // must do this first otherwise it might remove the global items just added + if global { + git_repo.remove_git_config_item("nostr.npub", false)?; + git_repo.remove_git_config_item("nostr.nsec", false)?; + git_repo.remove_git_config_item("nostr.bunker-uri", false)?; + git_repo.remove_git_config_item("nostr.bunker-app-key", false)?; + } + if let Some(bunker) = bunker { + git_repo.remove_git_config_item("nostr.nsec", global)?; + git_repo.save_git_config_item("nostr.bunker-uri", &bunker.0, global)?; + git_repo.save_git_config_item("nostr.bunker-app-key", &bunker.1, global)?; + } + if let Some(nsec) = nsec { + git_repo.save_git_config_item("nostr.nsec", nsec, global)?; + git_repo.remove_git_config_item("nostr.bunker-uri", global)?; + git_repo.remove_git_config_item("nostr.bunker-app-key", global)?; + } + git_repo.save_git_config_item("nostr.npub", npub, global) +} + fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result { decrypt_key( &git_repo - .get_git_config_item("nostr.nsec", false) + .get_git_config_item("nostr.nsec", None) .context("failed get git config")? .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?, password, @@ -158,15 +270,74 @@ fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result Result { - let nsec = &git_repo - .get_git_config_item("nostr.nsec", false) - .context("failed get git config")? - .context("git config item nostr.nsec doesn't exist")?; - if nsec.contains("ncryptsec") { - bail!("git config item nostr.nsec is an ncryptsec") +async fn get_nip46_signer_from_uri_and_key(uri: &str, app_key: &str) -> Result { + let term = console::Term::stderr(); + term.write_line("connecting to remote signer...")?; + let uri = NostrConnectURI::parse(uri)?; + let signer = NostrSigner::nip46( + Nip46Signer::new( + uri, + nostr::Keys::from_str(app_key).context("invalid app key")?, + Duration::from_secs(30), + None, + ) + .await?, + ); + term.clear_last_lines(1)?; + Ok(signer) +} + +async fn get_signer_with_git_config_nsec_or_bunker_without_prompts( + git_repo: &Repo, +) -> Result { + if let Ok(local_nsec) = &git_repo + .get_git_config_item("nostr.nsec", Some(false)) + .context("failed get local git config")? + .context("git local config item nostr.nsec doesn't exist") + { + if local_nsec.contains("ncryptsec") { + bail!("git global config item nostr.nsec is an ncryptsec") + } + Ok(NostrSigner::Keys( + nostr::Keys::from_str(local_nsec).context("invalid nsec parameter")?, + )) + } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(false)) + { + get_nip46_signer_from_uri_and_key(&uri, &app_key).await + } else if let Ok(global_nsec) = &git_repo + .get_git_config_item("nostr.nsec", Some(true)) + .context("failed get global git config")? + .context("git global config item nostr.nsec doesn't exist") + { + if global_nsec.contains("ncryptsec") { + bail!("git global config item nostr.nsec is an ncryptsec") + } + Ok(NostrSigner::Keys( + nostr::Keys::from_str(global_nsec).context("invalid nsec parameter")?, + )) + } else if let Ok((uri, app_key)) = get_git_config_bunker_uri_and_app_key(git_repo, Some(true)) { + get_nip46_signer_from_uri_and_key(&uri, &app_key).await + } else { + bail!("cannot get nsec or bunker from git config") } - nostr::Keys::from_str(nsec).context("invalid nsec parameter") +} + +fn get_git_config_bunker_uri_and_app_key( + git_repo: &Repo, + global: Option, +) -> Result<(String, String)> { + Ok(( + git_repo + .get_git_config_item("nostr.bunker_url", global) + .context("failed get local git config")? + .context("git local config item nostr.bunker_url doesn't exist")? + .to_string(), + git_repo + .get_git_config_item("nostr.bunker-app-key", global) + .context("failed get local git config")? + .context("git local config item nostr.bunker-app-key doesn't exist")? + .to_string(), + )) } async fn fresh_login( @@ -175,50 +346,119 @@ async fn fresh_login( #[cfg(not(test))] client: Option<&Client>, always_save: bool, ) -> Result<(NostrSigner, UserRef)> { + let mut public_key: Option = None; // prompt for nsec - let mut prompt = "login with nsec"; - let keys = loop { - match nostr::Keys::from_str( - &Interactor::default() - .input(PromptInputParms::default().with_prompt(prompt)) - .context("failed to get nsec input from interactor")?, - ) { + let mut prompt = "login with bunker uri / nsec"; + let signer = loop { + let input = Interactor::default() + .input(PromptInputParms::default().with_prompt(prompt)) + .context("failed to get nsec input from interactor")?; + match nostr::Keys::from_str(&input) { Ok(key) => { - break key; - } - Err(_) => { - prompt = "invalid nsec. try again with nsec (or hex private key)"; + if let Err(error) = save_keys(git_repo, &key, always_save) { + println!("{error}"); + } + break NostrSigner::Keys(key); } + Err(_) => match NostrConnectURI::parse(&input) { + Ok(_) => { + let app_key = Keys::generate().secret_key()?.to_secret_hex(); + match get_nip46_signer_from_uri_and_key(&input, &app_key).await { + Ok(signer) => { + let pub_key = fetch_public_key(&signer).await?; + if let Err(error) = + save_bunker(git_repo, &pub_key, &input, &app_key, always_save) + { + println!("{error}"); + } + public_key = Some(pub_key); + break signer; + } + Err(_) => { + prompt = "invalid. try again with nostr address / nsec"; + } + } + } + Err(_) => { + prompt = "invalid. try again with nostr address / nsec"; + } + }, } }; + let public_key = if let Some(public_key) = public_key { + public_key + } else { + signer.public_key().await? + }; // lookup profile - // save keys - if let Err(error) = save_keys(git_repo, &keys, always_save) { - println!("{error}"); - } - let user_ref = get_user_details(&keys.public_key(), client, git_repo).await?; + let user_ref = get_user_details(&public_key, client, git_repo).await?; print_logged_in_as(&user_ref, client.is_none())?; - Ok((NostrSigner::Keys(keys), user_ref)) + Ok((signer, user_ref)) } -fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { - let store = always_save +fn save_bunker( + git_repo: &Repo, + public_key: &PublicKey, + uri: &str, + app_key: &str, + always_save: bool, +) -> Result<()> { + if always_save || Interactor::default() - .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?; + .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? + { + let global = !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("just for this repository?") + .with_default(false), + )?; + let npub = public_key.to_bech32()?; + if let Err(error) = save_to_git_config( + git_repo, + &npub, + &None, + &Some((uri.to_string(), app_key.to_string())), + global, + ) { + if global { + if Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("save in repository git config?") + .with_default(true), + )? { + save_to_git_config( + git_repo, + &npub, + &None, + &Some((uri.to_string(), app_key.to_string())), + false, + )?; + } + } else { + Err(error)?; + } + }; + } + Ok(()) +} - let global = !Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("just for this repository?") - .with_default(false), - )?; +fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { + if always_save + || Interactor::default() + .confirm(PromptConfirmParms::default().with_prompt("save login details?"))? + { + let global = !Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("just for this repository?") + .with_default(false), + )?; - let encrypt = Interactor::default().confirm( - PromptConfirmParms::default() - .with_prompt("require password?") - .with_default(false), - )?; + let encrypt = Interactor::default().confirm( + PromptConfirmParms::default() + .with_prompt("require password?") + .with_default(false), + )?; - if store { let npub = keys.public_key().to_bech32()?; let nsec_string = if encrypt { let password = Interactor::default() @@ -233,22 +473,20 @@ fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<( keys.secret_key()?.to_bech32()? }; - if let Err(error) = git_repo.save_git_config_item("nostr.nsec", &nsec_string, global) { + if let Err(error) = + save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, global) + { if global { - println!("failed to edit global git config instead"); if Interactor::default().confirm( PromptConfirmParms::default() .with_prompt("save in repository git config?") .with_default(true), )? { - git_repo.save_git_config_item("nostr.nsec", &nsec_string, false)?; - git_repo.save_git_config_item("nostr.npub", &npub, false)?; + save_to_git_config(git_repo, &npub, &Some(nsec_string.clone()), &None, false)?; } } else { - bail!(error) + Err(error)?; } - } else { - git_repo.save_git_config_item("nostr.npub", &npub, global)?; }; }; Ok(()) @@ -256,7 +494,7 @@ fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<( fn get_config_item(git_repo: &Repo, name: &str) -> Result { git_repo - .get_git_config_item(name, false) + .get_git_config_item(name, None) .context("failed get git config")? .context(format!("git config item {name} doesn't exist")) } @@ -350,6 +588,10 @@ async fn get_user_details( println!("searching for profile and relay updates..."); } let database = SQLiteDatabase::open(if std::env::var("NGITTEST").is_err() { + create_dir_all(get_dirs()?.config_dir()).context(format!( + "cannot create cache directory in: {:?}", + get_dirs()?.config_dir() + ))?; get_dirs()?.config_dir().join("cache.sqlite") } else { git_repo.get_path()?.join(".git/test-global-cache.sqlite") diff --git a/src/main.rs b/src/main.rs index 30ecea3..9f53084 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ #![cfg_attr(not(test), warn(clippy::pedantic))] +#![allow(clippy::large_futures)] #![cfg_attr(not(test), warn(clippy::expect_used))] use anyhow::Result; @@ -19,6 +20,12 @@ mod sub_commands; pub struct Cli { #[command(subcommand)] command: Commands, + /// remote signer address + #[arg(long, global = true)] + bunker_uri: Option, + /// remote signer app secret key + #[arg(long, global = true)] + bunker_app_key: Option, /// nsec or hex private key #[arg(short, long, global = true)] nsec: Option, diff --git a/src/repo_ref.rs b/src/repo_ref.rs index 2b0d024..426640f 100644 --- a/src/repo_ref.rs +++ b/src/repo_ref.rs @@ -11,7 +11,7 @@ use crate::client::Client; use crate::client::MockConnect; use crate::{ cli_interactor::{Interactor, InteractorPrompt, PromptInputParms}, - client::Connect, + client::{sign_event, Connect}, git::{Repo, RepoActions}, }; @@ -95,8 +95,8 @@ pub static REPO_REF_KIND: u16 = 30_617; impl RepoRef { pub async fn to_event(&self, signer: &NostrSigner) -> Result { - signer - .sign_event_builder(nostr_sdk::EventBuilder::new( + sign_event( + nostr_sdk::EventBuilder::new( nostr::event::Kind::Custom(REPO_REF_KIND), "", [ @@ -152,9 +152,11 @@ impl RepoRef { // code languages and hashtags ] .concat(), - )) - .await - .context("failed to create repository reference event") + ), + signer, + ) + .await + .context("failed to create repository reference event") } } diff --git a/src/sub_commands/init.rs b/src/sub_commands/init.rs index 4afe83c..57785db 100644 --- a/src/sub_commands/init.rs +++ b/src/sub_commands/init.rs @@ -61,6 +61,8 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { let (signer, user_ref) = login::launch( &git_repo, + &cli_args.bunker_uri, + &cli_args.bunker_app_key, &cli_args.nsec, &cli_args.password, Some(&client), diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs index e71d431..6f49ba8 100644 --- a/src/sub_commands/login.rs +++ b/src/sub_commands/login.rs @@ -17,7 +17,16 @@ pub struct SubCommandArgs { pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { let git_repo = Repo::discover().context("cannot find a git repository")?; if command_args.offline { - login::launch(&git_repo, &args.nsec, &args.password, None, true).await?; + login::launch( + &git_repo, + &args.bunker_uri, + &args.bunker_app_key, + &args.nsec, + &args.password, + None, + true, + ) + .await?; Ok(()) } else { #[cfg(not(test))] @@ -25,7 +34,16 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { #[cfg(test)] let client = ::default(); - login::launch(&git_repo, &args.nsec, &args.password, Some(&client), true).await?; + login::launch( + &git_repo, + &args.bunker_uri, + &args.bunker_app_key, + &args.nsec, + &args.password, + Some(&client), + true, + ) + .await?; client.disconnect().await?; Ok(()) } diff --git a/src/sub_commands/push.rs b/src/sub_commands/push.rs index 92c1c18..3c471c0 100644 --- a/src/sub_commands/push.rs +++ b/src/sub_commands/push.rs @@ -150,6 +150,8 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { let (signer, user_ref) = login::launch( &git_repo, + &cli_args.bunker_uri, + &cli_args.bunker_app_key, &cli_args.nsec, &cli_args.password, Some(&client), diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs index 1d20e90..7c8f2ee 100644 --- a/src/sub_commands/send.rs +++ b/src/sub_commands/send.rs @@ -19,7 +19,7 @@ use crate::{ cli_interactor::{ Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptMultiChoiceParms, }, - client::Connect, + client::{sign_event, Connect}, git::{Repo, RepoActions}, login, repo_ref::{self, RepoRef, REPO_REF_KIND}, @@ -180,6 +180,8 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> { }; let (signer, user_ref) = login::launch( &git_repo, + &cli_args.bunker_uri, + &cli_args.bunker_app_key, &cli_args.nsec, &cli_args.password, Some(&client), @@ -593,7 +595,7 @@ pub async fn generate_cover_letter_and_patch_events( let mut events = vec![]; if let Some((title, description)) = cover_letter_title_description { - events.push(signer.sign_event_builder(EventBuilder::new( + events.push(sign_event(EventBuilder::new( nostr::event::Kind::Custom(PATCH_KIND), format!( "From {} Mon Sep 17 00:00:00 2001\nSubject: [PATCH 0/{}] {title}\n\n{description}", @@ -656,7 +658,7 @@ pub async fn generate_cover_letter_and_patch_events( .map(|pk| Tag::public_key(*pk)) .collect(), ].concat(), - )).await + ), signer).await .context("failed to create cover-letter event")?); } @@ -883,7 +885,7 @@ pub async fn generate_patch_event( .context("failed to get parent commit")?; let relay_hint = repo_ref.relays.first().map(nostr::UncheckedUrl::from); - signer.sign_event_builder(EventBuilder::new( + sign_event(EventBuilder::new( nostr::event::Kind::Custom(PATCH_KIND), git_repo .make_patch_from_commit(commit,&series_count) @@ -1000,7 +1002,7 @@ pub async fn generate_patch_event( ], ] .concat(), - )).await + ), signer).await .context("failed to sign event") } // TODO diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs index 64e3fff..ba9d30c 100644 --- a/test_utils/src/lib.rs +++ b/test_utils/src/lib.rs @@ -708,13 +708,14 @@ impl CliTester { formatter: ColorfulTheme::default(), } } - pub fn new_with_timeout(timeout_ms: u64, args: I) -> Self + pub fn new_with_timeout_from_dir(timeout_ms: u64, dir: &PathBuf, args: I) -> Self where I: IntoIterator, S: AsRef, { Self { - rexpect_session: rexpect_with(args, timeout_ms).expect("rexpect to spawn new process"), + rexpect_session: rexpect_with_from_dir(dir, args, timeout_ms) + .expect("rexpect to spawn new process"), formatter: ColorfulTheme::default(), } } diff --git a/tests/login.rs b/tests/login.rs index 03fc2a9..166941e 100644 --- a/tests/login.rs +++ b/tests/login.rs @@ -1,15 +1,17 @@ use anyhow::Result; +use git::GitTestRepo; use serial_test::serial; use test_utils::*; -static EXPECTED_NSEC_PROMPT: &str = "login with nsec"; +static EXPECTED_NSEC_PROMPT: &str = "login with bunker uri / nsec"; static EXPECTED_LOCAL_REPOSITORY_PROMPT: &str = "just for this repository?"; static EXPECTED_REQUIRE_PASSWORD_PROMPT: &str = "require password?"; static EXPECTED_SET_PASSWORD_PROMPT: &str = "encrypt with password"; static EXPECTED_SET_PASSWORD_CONFIRM_PROMPT: &str = "confirm password"; fn standard_first_time_login_encrypting_nsec() -> Result { - let mut p = CliTester::new(["login", "--offline"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login", "--offline"]); p.expect_input_eventually(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -57,7 +59,8 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new(["login"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -68,6 +71,8 @@ mod with_relays { p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))? .succeeds_with(Some(false))?; + p.expect("saved login details to local git config\r\n")?; + p.expect("searching for profile and relay updates...\r\n")?; p.expect_end_with("logged in as fred\r\n")?; @@ -94,7 +99,8 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new(["login"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -105,6 +111,8 @@ mod with_relays { p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))? .succeeds_with(Some(false))?; + p.expect("saved login details to local git config\r\n")?; + p.expect("searching for profile and relay updates...\r\n")?; p.expect("cannot extract account name from account metadata...\r\n")?; @@ -406,7 +414,13 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new(["login", "--nsec", TEST_KEY_1_NSEC]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["login", "--nsec", TEST_KEY_1_NSEC], + ); + + p.expect("saved login details to local git config\r\n")?; p.expect("searching for profile and relay updates...\r\n")?; @@ -456,17 +470,24 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - CliTester::new([ - "login", - "--offline", - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - ]) + let test_repo = GitTestRepo::default(); + CliTester::new_from_dir( + &test_repo.dir, + [ + "login", + "--offline", + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + ], + ) .expect_end_eventually()?; - let mut p = CliTester::new(["login", "--password", TEST_PASSWORD]); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["login", "--password", TEST_PASSWORD], + ); p.expect("searching for profile and relay updates...\r\n")?; @@ -516,13 +537,19 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new([ - "login", - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - ]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "login", + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + ], + ); + + p.expect("saved login details to local git config\r\n")?; p.expect("searching for profile and relay updates...\r\n")?; @@ -561,7 +588,8 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new(["login"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -572,6 +600,8 @@ mod with_relays { p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))? .succeeds_with(Some(false))?; + p.expect("saved login details to local git config\r\n")?; + p.expect("searching for profile and relay updates...\r\n")?; p.expect("cannot find your account metadata (name, etc) on relays\r\n")?; @@ -621,7 +651,8 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new(["login"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -632,6 +663,8 @@ mod with_relays { p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))? .succeeds_with(Some(false))?; + p.expect("saved login details to local git config\r\n")?; + p.expect("searching for profile and relay updates...\r\n")?; p.expect("cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience.\r\n")?; @@ -655,7 +688,7 @@ mod with_relays { mod when_second_time_login_and_details_already_fetched { use super::*; - mod uses_cache { + mod uses_cache_and_stores_and_retrieves_ncryptsec_from_local_git_config { use super::*; #[tokio::test] @@ -685,13 +718,19 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new([ - "login", - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - ]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "login", + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + ], + ); + + p.expect("saved login details to local git config\r\n")?; p.expect_end_eventually_with("logged in as fred\r\n")?; @@ -699,7 +738,10 @@ mod with_relays { shutdown_relay(8000 + p)?; } - let mut p = CliTester::new(["login", "--password", TEST_PASSWORD]); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["login", "--password", TEST_PASSWORD], + ); p.expect("searching for profile and relay updates...\r\n")?; @@ -734,7 +776,8 @@ mod with_relays { ); let cli_tester_handle = std::thread::spawn(move || -> Result<()> { - let mut p = CliTester::new(["login"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -745,6 +788,8 @@ mod with_relays { p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))? .succeeds_with(Some(false))?; + p.expect("saved login details to local git config\r\n")?; + p.expect("searching for profile and relay updates...\r\n")?; p.expect_end_with("logged in as fred\r\n")?; @@ -808,16 +853,15 @@ mod with_offline_flag { use super::*; #[test] - #[serial] fn prompts_for_nsec_and_password() -> Result<()> { standard_first_time_login_encrypting_nsec()?; Ok(()) } #[test] - #[serial] fn succeeds_with_text_logged_in_as_npub() -> Result<()> { - let mut p = CliTester::new(["login", "--offline"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login", "--offline"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -832,13 +876,15 @@ mod with_offline_flag { .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? .succeeds_with(TEST_PASSWORD)?; + p.expect("saved login details to local git config\r\n")?; + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) } #[test] - #[serial] fn succeeds_with_hex_secret_key_in_place_of_nsec() -> Result<()> { - let mut p = CliTester::new(["login", "--offline"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login", "--offline"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_SK_HEX)?; @@ -853,6 +899,8 @@ mod with_offline_flag { .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? .succeeds_with(TEST_PASSWORD)?; + p.expect("saved login details to local git config\r\n")?; + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) } @@ -860,12 +908,11 @@ mod with_offline_flag { use super::*; #[test] - #[serial] fn prompts_for_nsec_until_valid() -> Result<()> { - let invalid_nsec_response = - "invalid nsec. try again with nsec (or hex private key)"; + let invalid_nsec_response = "invalid. try again with nostr address / nsec"; - let mut p = CliTester::new(["login", "--offline"]); + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir(&test_repo.dir, ["login", "--offline"]); p.expect_input(EXPECTED_NSEC_PROMPT)? // this behaviour is intentional. rejecting the response with dialoguer @@ -889,6 +936,8 @@ mod with_offline_flag { .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? .succeeds_with(TEST_PASSWORD)?; + p.expect("saved login details to local git config\r\n")?; + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) } } @@ -898,19 +947,31 @@ mod with_offline_flag { use super::*; #[test] - #[serial] fn valid_nsec_param_succeeds_without_prompts() -> Result<()> { - CliTester::new(["login", "--offline", "--nsec", TEST_KEY_1_NSEC]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["login", "--offline", "--nsec", TEST_KEY_1_NSEC], + ); + + p.expect("saved login details to local git config\r\n")?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) } #[test] - #[serial] fn forgets_identity() -> Result<()> { - CliTester::new(["login", "--offline", "--nsec", TEST_KEY_1_NSEC]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["login", "--offline", "--nsec", TEST_KEY_1_NSEC], + ); + + p.expect("saved login details to local git config\r\n")?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; - let mut p = CliTester::new(["login", "--offline"]); + p = CliTester::new_from_dir(&test_repo.dir, ["login", "--offline"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -922,18 +983,28 @@ mod with_offline_flag { use super::*; #[test] - #[serial] fn valid_nsec_param_succeeds_without_prompts_and_logs_in() -> Result<()> { standard_first_time_login_encrypting_nsec()?.exit()?; + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["login", "--offline", "--nsec", TEST_KEY_2_NSEC], + ); - CliTester::new(["login", "--offline", "--nsec", TEST_KEY_2_NSEC]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str()) + p.expect("saved login details to local git config\r\n")?; + + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str()) } } #[test] - #[serial] fn invalid_nsec_param_fails_without_prompts() -> Result<()> { - CliTester::new(["login", "--offline", "--nsec", TEST_INVALID_NSEC]).expect_end_with( + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + ["login", "--offline", "--nsec", TEST_INVALID_NSEC], + ); + + p.expect_end_with( "Error: invalid nsec parameter\r\n\r\nCaused by:\r\n Invalid secret key\r\n", ) } @@ -943,50 +1014,61 @@ mod with_offline_flag { use super::*; #[test] - #[serial] fn valid_nsec_param_succeeds_without_prompts() -> Result<()> { - CliTester::new([ - "login", - "--offline", - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - ]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "login", + "--offline", + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + ], + ); + p.expect("saved login details to local git config\r\n")?; + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) } #[test] - #[serial] fn parameters_can_be_called_globally() -> Result<()> { - CliTester::new([ - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_PASSWORD, - "login", - "--offline", - ]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_PASSWORD, + "login", + "--offline", + ], + ); + p.expect("saved login details to local git config\r\n")?; + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) } mod when_logging_in_as_different_nsec { use super::*; #[test] - #[serial] fn valid_nsec_param_succeeds_without_prompts_and_logs_in() -> Result<()> { standard_first_time_login_encrypting_nsec()?.exit()?; - - CliTester::new([ - "login", - "--offline", - "--nsec", - TEST_KEY_2_NSEC, - "--password", - TEST_PASSWORD, - ]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str()) + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "login", + "--offline", + "--nsec", + TEST_KEY_2_NSEC, + "--password", + TEST_PASSWORD, + ], + ); + p.expect("saved login details to local git config\r\n")?; + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str()) } } @@ -994,37 +1076,46 @@ mod with_offline_flag { use super::*; #[test] - #[serial] fn password_changes() -> Result<()> { standard_first_time_login_encrypting_nsec()?.exit()?; + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "login", + "--offline", + "--nsec", + TEST_KEY_1_NSEC, + "--password", + TEST_INVALID_PASSWORD, + ], + ); + p.expect("saved login details to local git config\r\n")?; + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; - CliTester::new([ - "login", - "--offline", - "--nsec", - TEST_KEY_1_NSEC, - "--password", - TEST_INVALID_PASSWORD, - ]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; - - CliTester::new(["--password", TEST_INVALID_PASSWORD, "login", "--offline"]) - .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) + CliTester::new_from_dir( + &test_repo.dir, + ["--password", TEST_INVALID_PASSWORD, "login", "--offline"], + ) + .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) } } #[test] - #[serial] fn invalid_nsec_param_fails_without_prompts() -> Result<()> { - CliTester::new([ - "login", - "--offline", - "--nsec", - TEST_INVALID_NSEC, - "--password", - TEST_PASSWORD, - ]) - .expect_end_with( + let test_repo = GitTestRepo::default(); + let mut p = CliTester::new_from_dir( + &test_repo.dir, + [ + "login", + "--offline", + "--nsec", + TEST_INVALID_NSEC, + "--password", + TEST_PASSWORD, + ], + ); + p.expect_end_with( "Error: invalid nsec parameter\r\n\r\nCaused by:\r\n Invalid secret key\r\n", ) } @@ -1034,11 +1125,12 @@ mod with_offline_flag { use super::*; #[test] - #[serial] // combined into a single test as it is computationally expensive to run fn warns_it_might_take_a_few_seconds_then_succeeds_then_second_login_prompts_for_password_then_warns_again_then_succeeds() -> Result<()> { - let mut p = CliTester::new_with_timeout(10000, ["login", "--offline"]); + let test_repo = GitTestRepo::default(); + let mut p = + CliTester::new_with_timeout_from_dir(10000, &test_repo.dir, ["login", "--offline"]); p.expect_input(EXPECTED_NSEC_PROMPT)? .succeeds_with(TEST_KEY_1_NSEC)?; @@ -1054,6 +1146,8 @@ mod with_offline_flag { p.expect("this may take a few seconds...\r\n")?; + p.expect("saved login details to local git config\r\n")?; + p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) // commented out as 'login' command now assumes you want to -- cgit v1.2.3