upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDanConwayDev <DanConwayDev@protonmail.com>2024-06-24 09:39:18 +0100
committerDanConwayDev <DanConwayDev@protonmail.com>2024-06-24 09:39:18 +0100
commit173ab188b326fbe78cfba4ab455a74619f4556bb (patch)
tree743a2413c241f7babd4efb336718c510eb743847
parent681fdd7683363c62251ecd8dabcc1931a18f4840 (diff)
feat(login): store in git config and use cache
replace ngit yaml file config with: * nsec / ncryptsec / npub in git config in nostr.* namespace * sql database cache for metadata and relay events allow different logins to be used for different git repositories by storing login in local git config
-rw-r--r--Cargo.lock74
-rw-r--r--Cargo.toml2
-rw-r--r--src/config.rs189
-rw-r--r--src/key_handling/encryption.rs77
-rw-r--r--src/key_handling/mod.rs1
-rw-r--r--src/key_handling/users.rs1174
-rw-r--r--src/login.rs461
-rw-r--r--src/sub_commands/init.rs9
-rw-r--r--src/sub_commands/login.rs9
-rw-r--r--src/sub_commands/push.rs9
-rw-r--r--src/sub_commands/send.rs9
-rw-r--r--test_utils/src/lib.rs13
-rw-r--r--tests/init.rs1
-rw-r--r--tests/login.rs254
-rw-r--r--tests/push.rs2
-rw-r--r--tests/send.rs3
16 files changed, 593 insertions, 1694 deletions
diff --git a/Cargo.lock b/Cargo.lock
index bf0032e..850da5b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -956,6 +956,18 @@ dependencies = [
956] 956]
957 957
958[[package]] 958[[package]]
959name = "fallible-iterator"
960version = "0.3.0"
961source = "registry+https://github.com/rust-lang/crates.io-index"
962checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
963
964[[package]]
965name = "fallible-streaming-iterator"
966version = "0.1.9"
967source = "registry+https://github.com/rust-lang/crates.io-index"
968checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
969
970[[package]]
959name = "fastrand" 971name = "fastrand"
960version = "1.9.0" 972version = "1.9.0"
961source = "registry+https://github.com/rust-lang/crates.io-index" 973source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -971,6 +983,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
971checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" 983checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984"
972 984
973[[package]] 985[[package]]
986name = "flatbuffers"
987version = "23.5.26"
988source = "registry+https://github.com/rust-lang/crates.io-index"
989checksum = "4dac53e22462d78c16d64a1cd22371b54cc3fe94aa15e7886a2fa6e5d1ab8640"
990dependencies = [
991 "bitflags 1.3.2",
992 "rustc_version",
993]
994
995[[package]]
974name = "float-cmp" 996name = "float-cmp"
975version = "0.9.0" 997version = "0.9.0"
976source = "registry+https://github.com/rust-lang/crates.io-index" 998source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1197,6 +1219,15 @@ dependencies = [
1197] 1219]
1198 1220
1199[[package]] 1221[[package]]
1222name = "hashlink"
1223version = "0.9.1"
1224source = "registry+https://github.com/rust-lang/crates.io-index"
1225checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
1226dependencies = [
1227 "hashbrown",
1228]
1229
1230[[package]]
1200name = "heck" 1231name = "heck"
1201version = "0.4.1" 1232version = "0.4.1"
1202source = "registry+https://github.com/rust-lang/crates.io-index" 1233source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1513,6 +1544,17 @@ dependencies = [
1513] 1544]
1514 1545
1515[[package]] 1546[[package]]
1547name = "libsqlite3-sys"
1548version = "0.28.0"
1549source = "registry+https://github.com/rust-lang/crates.io-index"
1550checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f"
1551dependencies = [
1552 "cc",
1553 "pkg-config",
1554 "vcpkg",
1555]
1556
1557[[package]]
1516name = "libssh2-sys" 1558name = "libssh2-sys"
1517version = "0.3.0" 1559version = "0.3.0"
1518source = "registry+https://github.com/rust-lang/crates.io-index" 1560source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1717,7 +1759,9 @@ dependencies = [
1717 "keyring", 1759 "keyring",
1718 "mockall", 1760 "mockall",
1719 "nostr", 1761 "nostr",
1762 "nostr-database",
1720 "nostr-sdk", 1763 "nostr-sdk",
1764 "nostr-sqlite",
1721 "once_cell", 1765 "once_cell",
1722 "passwords", 1766 "passwords",
1723 "rexpect 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", 1767 "rexpect 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1811,6 +1855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1811checksum = "a88a72f92fbd5d2514db36e07a864646f1c1f44931c4a5ea195f6961029af4b3" 1855checksum = "a88a72f92fbd5d2514db36e07a864646f1c1f44931c4a5ea195f6961029af4b3"
1812dependencies = [ 1856dependencies = [
1813 "async-trait", 1857 "async-trait",
1858 "flatbuffers",
1814 "lru", 1859 "lru",
1815 "nostr", 1860 "nostr",
1816 "thiserror", 1861 "thiserror",
@@ -1869,6 +1914,21 @@ dependencies = [
1869] 1914]
1870 1915
1871[[package]] 1916[[package]]
1917name = "nostr-sqlite"
1918version = "0.32.0"
1919source = "registry+https://github.com/rust-lang/crates.io-index"
1920checksum = "418555707a30105f738b3a54a1ae13ffca5e7ec10b4d27a8c20bedde636233c3"
1921dependencies = [
1922 "async-trait",
1923 "nostr",
1924 "nostr-database",
1925 "rusqlite",
1926 "thiserror",
1927 "tokio",
1928 "tracing",
1929]
1930
1931[[package]]
1872name = "nostr-zapper" 1932name = "nostr-zapper"
1873version = "0.32.0" 1933version = "0.32.0"
1874source = "registry+https://github.com/rust-lang/crates.io-index" 1934source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2509,6 +2569,20 @@ dependencies = [
2509] 2569]
2510 2570
2511[[package]] 2571[[package]]
2572name = "rusqlite"
2573version = "0.31.0"
2574source = "registry+https://github.com/rust-lang/crates.io-index"
2575checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae"
2576dependencies = [
2577 "bitflags 2.5.0",
2578 "fallible-iterator",
2579 "fallible-streaming-iterator",
2580 "hashlink",
2581 "libsqlite3-sys",
2582 "smallvec",
2583]
2584
2585[[package]]
2512name = "rustc-demangle" 2586name = "rustc-demangle"
2513version = "0.1.23" 2587version = "0.1.23"
2514source = "registry+https://github.com/rust-lang/crates.io-index" 2588source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index a57f9d4..e25fd51 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,7 +24,9 @@ git2 = "0.18.1"
24indicatif = "0.17.7" 24indicatif = "0.17.7"
25keyring = "2.0.5" 25keyring = "2.0.5"
26nostr = "0.32.0" 26nostr = "0.32.0"
27nostr-database = "0.32.0"
27nostr-sdk = "0.32.0" 28nostr-sdk = "0.32.0"
29nostr-sqlite = "0.32.0"
28passwords = "3.1.13" 30passwords = "3.1.13"
29scrypt = "0.11.0" 31scrypt = "0.11.0"
30serde = { version = "1.0.181", features = ["derive"] } 32serde = { version = "1.0.181", features = ["derive"] }
diff --git a/src/config.rs b/src/config.rs
index 7fca446..56619b8 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,107 +1,19 @@
1use std::{fs::File, io::BufReader}; 1use anyhow::{anyhow, Result};
2
3use anyhow::{anyhow, Context, Result};
4use directories::ProjectDirs; 2use directories::ProjectDirs;
5#[cfg(test)] 3use nostr::PublicKey;
6use mockall::*;
7use nostr::{PublicKey, ToBech32};
8use serde::{self, Deserialize, Serialize}; 4use serde::{self, Deserialize, Serialize};
9 5
10#[derive(Default)]
11#[allow(clippy::module_name_repetitions)]
12pub struct ConfigManager;
13
14#[cfg_attr(test, automock)]
15#[allow(clippy::module_name_repetitions)]
16pub trait ConfigManagement {
17 fn load(&self) -> Result<MyConfig>;
18 fn save(&self, cfg: &MyConfig) -> Result<()>;
19}
20
21pub fn get_dirs() -> Result<ProjectDirs> { 6pub fn get_dirs() -> Result<ProjectDirs> {
22 ProjectDirs::from("", "CodeCollaboration", "ngit").ok_or(anyhow!( 7 ProjectDirs::from("", "CodeCollaboration", "ngit").ok_or(anyhow!(
23 "should find operating system home directories with rust-directories crate" 8 "should find operating system home directories with rust-directories crate"
24 )) 9 ))
25} 10}
26 11
27impl ConfigManagement for ConfigManager {
28 fn load(&self) -> Result<MyConfig> {
29 let config_path = get_dirs()?.config_dir().join("config.json");
30 if config_path.exists() {
31 let file =
32 File::open(config_path).context("should open application configuration file")?;
33 let reader = BufReader::new(file);
34 let config: MyConfig = serde_json::from_reader(reader)
35 .context("should read config from config file with serde_json")?;
36 Ok(config)
37 } else {
38 Ok(MyConfig::default())
39 }
40 }
41 fn save(&self, cfg: &MyConfig) -> Result<()> {
42 let dirs = get_dirs()?;
43 let config_path = dirs.config_dir().join("config.json");
44 let file = if config_path.exists() {
45 std::fs::OpenOptions::new()
46 .create(true)
47 .write(true)
48 .truncate(true)
49 .open(config_path)
50 .context(
51 "should open application configuration file with write and truncate options",
52 )?
53 } else {
54 std::fs::create_dir_all(dirs.config_dir())
55 .context("should create application config directories")?;
56 std::fs::File::create(config_path).context("should create application config file")?
57 };
58 serde_json::to_writer_pretty(file, cfg)
59 .context("should write configuration to config file with serde_json")
60 }
61}
62
63#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)]
64#[allow(clippy::module_name_repetitions)]
65pub struct MyConfig {
66 pub version: u8,
67 pub users: Vec<UserRef>,
68}
69
70#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 12#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
71pub struct UserRef { 13pub struct UserRef {
72 pub public_key: PublicKey, 14 pub public_key: PublicKey,
73 pub encrypted_key: String,
74 pub metadata: UserMetadata, 15 pub metadata: UserMetadata,
75 pub relays: UserRelays, 16 pub relays: UserRelays,
76 pub last_checked: u64,
77}
78
79impl UserRef {
80 pub fn new(public_key: PublicKey, encrypted_key: String) -> Self {
81 Self {
82 public_key,
83 encrypted_key,
84 relays: UserRelays {
85 relays: vec![],
86 created_at: 0,
87 },
88 metadata: UserMetadata {
89 #[allow(clippy::expect_used)]
90 name: public_key
91 .to_bech32()
92 .expect("public key should always produce bech32"),
93 // name: format!(
94 // "{}",
95 // public_key
96 // .to_bech32()
97 // .expect("public key should always produce bech32"),
98 // )
99 // .as_str()[..10].to_string(),
100 created_at: 0,
101 },
102 last_checked: 0,
103 }
104 }
105} 17}
106 18
107#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] 19#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
@@ -132,100 +44,3 @@ pub struct UserRelayRef {
132 pub read: bool, 44 pub read: bool,
133 pub write: bool, 45 pub write: bool,
134} 46}
135
136#[cfg(test)]
137mod tests {
138 use anyhow::Result;
139 use serial_test::serial;
140
141 use super::*;
142
143 fn backup_existing_config() -> Result<()> {
144 let config_path = get_dirs()?.config_dir().join("config.json");
145 let backup_config_path = get_dirs()?.config_dir().join("config-backup.json");
146 if config_path.exists() {
147 std::fs::rename(config_path, backup_config_path)?;
148 }
149 Ok(())
150 }
151
152 fn restore_config_backup() -> Result<()> {
153 let config_path = get_dirs()?.config_dir().join("config.json");
154 let backup_config_path = get_dirs()?.config_dir().join("config-backup.json");
155 if config_path.exists() {
156 std::fs::remove_file(&config_path)?;
157 }
158 if backup_config_path.exists() {
159 std::fs::rename(backup_config_path, config_path)?;
160 }
161 Ok(())
162 }
163
164 mod load {
165 use super::*;
166
167 #[test]
168 #[serial]
169 fn when_config_file_doesnt_exist_defaults_are_returned() -> Result<()> {
170 backup_existing_config()?;
171 let c = ConfigManager;
172 assert_eq!(c.load()?, MyConfig::default());
173 restore_config_backup()?;
174 Ok(())
175 }
176
177 #[test]
178 #[serial]
179 fn when_config_file_exists_it_is_returned() -> Result<()> {
180 backup_existing_config()?;
181 let c = ConfigManager;
182 let new_config = MyConfig {
183 version: 255,
184 ..MyConfig::default()
185 };
186 c.save(&new_config)?;
187 assert_eq!(c.load()?, new_config);
188 restore_config_backup()?;
189 Ok(())
190 }
191 }
192
193 mod save {
194 use super::*;
195
196 #[test]
197 #[serial]
198 fn when_config_file_doesnt_config_is_saved() -> Result<()> {
199 backup_existing_config()?;
200 let c = ConfigManager;
201 let new_config = MyConfig {
202 version: 255,
203 ..MyConfig::default()
204 };
205 c.save(&new_config)?;
206 assert_eq!(c.load().unwrap(), new_config);
207 restore_config_backup()?;
208 Ok(())
209 }
210
211 #[test]
212 #[serial]
213 fn when_config_file_exists_new_config_is_saved() -> Result<()> {
214 backup_existing_config()?;
215 let c = ConfigManager;
216 let config = MyConfig {
217 version: 255,
218 ..MyConfig::default()
219 };
220 c.save(&config)?;
221 let new_config = MyConfig {
222 version: 254,
223 ..MyConfig::default()
224 };
225 c.save(&new_config)?;
226 assert_eq!(c.load().unwrap(), new_config);
227 restore_config_backup()?;
228 Ok(())
229 }
230 }
231}
diff --git a/src/key_handling/encryption.rs b/src/key_handling/encryption.rs
index 3f4ee41..3841d50 100644
--- a/src/key_handling/encryption.rs
+++ b/src/key_handling/encryption.rs
@@ -1,46 +1,31 @@
1use anyhow::Result; 1use anyhow::Result;
2#[cfg(test)]
3use mockall::*;
4use nostr::{prelude::*, Keys}; 2use nostr::{prelude::*, Keys};
5 3
6#[derive(Default)] 4pub fn encrypt_key(keys: &Keys, password: &str) -> Result<String> {
7pub struct Encryptor; 5 let log2_rounds: u8 = if password.len() > 20 {
8 6 // we have enough of entropy - no need to spend CPU time adding much more
9#[cfg_attr(test, automock)] 7 1
10pub trait EncryptDecrypt { 8 } else {
11 /// requires less CPU time if the password is long 9 println!("this may take a few seconds...");
12 fn encrypt_key(&self, keys: &Keys, password: &str) -> Result<String>; 10 // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait
13 fn decrypt_key(&self, encrypted_key: &str, password: &str) -> Result<Keys>; 11 15
12 };
13 Ok(nostr::nips::nip49::EncryptedSecretKey::new(
14 keys.secret_key()?,
15 password,
16 log2_rounds,
17 KeySecurity::Medium,
18 )?
19 .to_bech32()?)
14} 20}
15 21
16/// approach and code adapted from nostr gossip client 22pub fn decrypt_key(encrypted_key: &str, password: &str) -> Result<nostr::Keys> {
17impl EncryptDecrypt for Encryptor { 23 let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?;
18 fn encrypt_key(&self, keys: &Keys, password: &str) -> Result<String> { 24 // to request that log_n gets exposed
19 let log2_rounds: u8 = if password.len() > 20 { 25 if encrypted_key.log_n() > 14 {
20 // we have enough of entropy - no need to spend CPU time adding much more 26 println!("this may take a few seconds...");
21 1
22 } else {
23 println!("this may take a few seconds...");
24 // default (scrypt::Params::RECOMMENDED_LOG_N) is 17 but 30s is too long to wait
25 15
26 };
27 Ok(nostr::nips::nip49::EncryptedSecretKey::new(
28 keys.secret_key()?,
29 password,
30 log2_rounds,
31 KeySecurity::Medium,
32 )?
33 .to_bech32()?)
34 }
35
36 fn decrypt_key(&self, encrypted_key: &str, password: &str) -> Result<nostr::Keys> {
37 let encrypted_key = nostr::nips::nip49::EncryptedSecretKey::from_bech32(encrypted_key)?;
38 // to request that log_n gets exposed
39 if encrypted_key.log_n() > 14 {
40 println!("this may take a few seconds...");
41 }
42 Ok(nostr::Keys::new(encrypted_key.to_secret_key(password)?))
43 } 27 }
28 Ok(nostr::Keys::new(encrypted_key.to_secret_key(password)?))
44} 29}
45 30
46#[cfg(test)] 31#[cfg(test)]
@@ -51,7 +36,7 @@ mod tests {
51 36
52 #[test] 37 #[test]
53 fn encrypt_key_produces_string_prefixed_with() -> Result<()> { 38 fn encrypt_key_produces_string_prefixed_with() -> Result<()> {
54 let s = Encryptor.encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?; 39 let s = encrypt_key(&nostr::Keys::generate(), TEST_PASSWORD)?;
55 assert!(s.starts_with("ncryptsec")); 40 assert!(s.starts_with("ncryptsec"));
56 Ok(()) 41 Ok(())
57 } 42 }
@@ -59,8 +44,7 @@ mod tests {
59 #[test] 44 #[test]
60 // ensures password encryption hasn't changed 45 // ensures password encryption hasn't changed
61 fn decrypts_with_strong_password_from_reference_string() -> Result<()> { 46 fn decrypts_with_strong_password_from_reference_string() -> Result<()> {
62 let encryptor = Encryptor; 47 let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED, TEST_PASSWORD)?;
63 let decrypted_key = encryptor.decrypt_key(TEST_KEY_1_ENCRYPTED, TEST_PASSWORD)?;
64 48
65 assert_eq!( 49 assert_eq!(
66 format!( 50 format!(
@@ -78,8 +62,7 @@ mod tests {
78 #[test] 62 #[test]
79 // ensures password encryption hasn't changed 63 // ensures password encryption hasn't changed
80 fn decrypts_with_weak_password_from_reference_string() -> Result<()> { 64 fn decrypts_with_weak_password_from_reference_string() -> Result<()> {
81 let encryptor = Encryptor; 65 let decrypted_key = decrypt_key(TEST_KEY_1_ENCRYPTED_WEAK, TEST_WEAK_PASSWORD)?;
82 let decrypted_key = encryptor.decrypt_key(TEST_KEY_1_ENCRYPTED_WEAK, TEST_WEAK_PASSWORD)?;
83 66
84 assert_eq!( 67 assert_eq!(
85 format!( 68 format!(
@@ -96,10 +79,9 @@ mod tests {
96 79
97 #[test] 80 #[test]
98 fn decrypts_key_encrypted_using_encrypt_key() -> Result<()> { 81 fn decrypts_key_encrypted_using_encrypt_key() -> Result<()> {
99 let encryptor = Encryptor;
100 let key = nostr::Keys::generate(); 82 let key = nostr::Keys::generate();
101 let s = encryptor.encrypt_key(&key, TEST_PASSWORD)?; 83 let s = encrypt_key(&key, TEST_PASSWORD)?;
102 let newkey = encryptor.decrypt_key(s.as_str(), TEST_PASSWORD)?; 84 let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?;
103 85
104 assert_eq!( 86 assert_eq!(
105 format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), 87 format!("{}", key.secret_key().unwrap().to_bech32().unwrap()),
@@ -110,10 +92,9 @@ mod tests {
110 92
111 #[test] 93 #[test]
112 fn decrypt_key_successfully_decrypts_key_encrypted_using_encrypt_key() -> Result<()> { 94 fn decrypt_key_successfully_decrypts_key_encrypted_using_encrypt_key() -> Result<()> {
113 let encryptor = Encryptor;
114 let key = nostr::Keys::generate(); 95 let key = nostr::Keys::generate();
115 let s = encryptor.encrypt_key(&key, TEST_PASSWORD)?; 96 let s = encrypt_key(&key, TEST_PASSWORD)?;
116 let newkey = encryptor.decrypt_key(s.as_str(), TEST_PASSWORD)?; 97 let newkey = decrypt_key(s.as_str(), TEST_PASSWORD)?;
117 98
118 assert_eq!( 99 assert_eq!(
119 format!("{}", key.secret_key().unwrap().to_bech32().unwrap()), 100 format!("{}", key.secret_key().unwrap().to_bech32().unwrap()),
diff --git a/src/key_handling/mod.rs b/src/key_handling/mod.rs
index bcb10df..81c4253 100644
--- a/src/key_handling/mod.rs
+++ b/src/key_handling/mod.rs
@@ -1,2 +1 @@
1pub mod encryption; pub mod encryption;
2pub mod users;
diff --git a/src/key_handling/users.rs b/src/key_handling/users.rs
deleted file mode 100644
index a79a977..0000000
--- a/src/key_handling/users.rs
+++ /dev/null
@@ -1,1174 +0,0 @@
1use std::{str::FromStr, time::SystemTime};
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use nostr::prelude::*;
6use zeroize::Zeroize;
7
8use super::encryption::{EncryptDecrypt, Encryptor};
9#[cfg(not(test))]
10use crate::client::Client;
11#[cfg(test)]
12use crate::client::MockConnect;
13use crate::{
14 cli_interactor::{Interactor, InteractorPrompt, PromptInputParms, PromptPasswordParms},
15 client::Connect,
16 config::{
17 self, ConfigManagement, ConfigManager, UserMetadata, UserRef, UserRelayRef, UserRelays,
18 },
19};
20
21#[derive(Default)]
22pub struct UserManager {
23 config_manager: ConfigManager,
24 interactor: Interactor,
25 encryptor: Encryptor,
26}
27
28#[async_trait]
29pub trait UserManagement {
30 fn add(&self, nsec: &Option<String>, password: &Option<String>) -> Result<nostr::Keys>;
31 async fn get_user(
32 &self,
33 #[cfg(test)] client: &MockConnect,
34 #[cfg(not(test))] client: &Client,
35 public_key: &PublicKey,
36 after: u64,
37 ) -> Result<UserRef>;
38 fn get_user_from_cache(&self, public_key: &PublicKey) -> Result<UserRef>;
39 fn add_user_to_config(
40 &self,
41 public_key: PublicKey,
42 encrypted_secret_key: Option<String>,
43 overwrite: bool,
44 ) -> Result<()>;
45}
46
47#[cfg(test)]
48use duplicate::duplicate_item;
49#[cfg_attr(test, duplicate_item(UserManager; [UserManager]; [self::tests::MockUserManager]))]
50#[async_trait]
51impl UserManagement for UserManager {
52 fn add(&self, nsec: &Option<String>, password: &Option<String>) -> Result<nostr::Keys> {
53 let mut prompt = "login with nsec (or hex private key)";
54 let keys = loop {
55 let pk = match nsec.clone() {
56 Some(nsec) => nsec,
57 None => self
58 .interactor
59 .input(PromptInputParms::default().with_prompt(prompt))
60 .context("failed to get nsec input from interactor")?,
61 };
62 match Keys::from_str(&pk) {
63 Ok(key) => {
64 break key;
65 }
66 Err(e) => {
67 if nsec.is_some() {
68 return Err(e).context(
69 "invalid nsec - supplied parameter could not be converted into a nostr private key",
70 );
71 }
72 prompt = "invalid nsec. try again with nsec (or hex private key)";
73 }
74 }
75 };
76
77 let mut pass = match password.clone() {
78 Some(pass) => pass,
79 None => self
80 .interactor
81 .password(
82 PromptPasswordParms::default()
83 .with_prompt("encrypt with password")
84 .with_confirm(),
85 )
86 .context("failed to get password input from interactor.password")?,
87 };
88
89 let encrypted_secret_key = self
90 .encryptor
91 .encrypt_key(&keys, &pass)
92 .context("failed to encrypt nsec with password.")?;
93 pass.zeroize();
94
95 self.add_user_to_config(keys.public_key(), Some(encrypted_secret_key), true)?;
96
97 Ok(keys)
98 }
99
100 fn add_user_to_config(
101 &self,
102 public_key: PublicKey,
103 encrypted_secret_key: Option<String>,
104 overwrite: bool,
105 ) -> Result<()> {
106 let user_ref = config::UserRef::new(public_key, encrypted_secret_key.unwrap_or_default());
107
108 let mut cfg = self.config_manager.load().context("failed to load application config to find and remove any old versions of the user's encrypted key")?;
109 // don't overwrite unless specified
110 if !overwrite
111 && cfg
112 .users
113 .clone()
114 .into_iter()
115 .any(|r| r.public_key.eq(&public_key))
116 {
117 return Ok(());
118 }
119 // if overwrite remove any duplicate entries for key before adding it to config
120 cfg.users = cfg
121 .users
122 .clone()
123 .into_iter()
124 .filter(|r| !r.public_key.eq(&public_key))
125 .collect();
126 cfg.users.push(user_ref);
127 self.config_manager
128 .save(&cfg)
129 .context("failed to save application configuration with new user details in")
130 }
131
132 fn get_user_from_cache(&self, public_key: &PublicKey) -> Result<UserRef> {
133 let cfg = self
134 .config_manager
135 .load()
136 .context("failed to load application config")?;
137 Ok(cfg
138 .users
139 .iter()
140 .find(|u| u.public_key.eq(public_key))
141 .context(format!("pubkey isn't a current user: {public_key}"))?
142 .clone())
143 }
144 /// get UserRef fetching most recent user relays and metadata infomation
145 /// from
146 #[allow(clippy::too_many_lines)]
147 async fn get_user(
148 &self,
149 #[cfg(test)] client: &MockConnect,
150 #[cfg(not(test))] client: &Client,
151 public_key: &PublicKey,
152 use_cache_unless_checked_more_than_x_secs_ago: u64,
153 ) -> Result<UserRef> {
154 let cfg = self
155 .config_manager
156 .load()
157 .context("failed to load application config")?;
158 let mut user_ref = cfg
159 .users
160 .iter()
161 .find(|u| u.public_key.eq(public_key))
162 .context(format!("pubkey isn't a current user: {public_key}"))?
163 .clone();
164 // return cache if last fetched was within X minutes
165 if !unix_timestamp_after_now_plus_secs(
166 user_ref.last_checked,
167 use_cache_unless_checked_more_than_x_secs_ago,
168 ) {
169 return Ok(user_ref);
170 }
171
172 let mut relays_to_search = if user_ref.relays.write().is_empty() {
173 client.get_fallback_relays().clone()
174 } else {
175 user_ref.relays.write()
176 };
177
178 let mut relays_searched: Vec<String> = vec![];
179
180 loop {
181 for r in &relays_to_search {
182 if !relays_searched.iter().any(|sr| r.eq(sr)) {
183 relays_searched.push(r.clone());
184 }
185 }
186
187 let events: Vec<Event> = match client
188 .get_events(
189 relays_to_search,
190 vec![
191 nostr::Filter::default()
192 .author(*public_key)
193 .since(nostr::Timestamp::from(user_ref.metadata.created_at + 1))
194 .kind(Kind::Metadata),
195 nostr::Filter::default()
196 .author(*public_key)
197 .since(nostr::Timestamp::from(user_ref.relays.created_at + 1))
198 .kind(Kind::RelayList),
199 ],
200 )
201 .await
202 {
203 Ok(events) => events,
204 Err(_) => {
205 return Ok(user_ref.clone());
206 }
207 };
208
209 user_ref.last_checked = SystemTime::now()
210 .duration_since(SystemTime::UNIX_EPOCH)
211 .context("system time should be after the year 1970")?
212 .as_secs();
213
214 if let Some(new_metadata_event) = events
215 .iter()
216 .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key))
217 .max_by_key(|e| e.created_at)
218 {
219 if new_metadata_event.created_at.as_u64() > user_ref.metadata.created_at {
220 let metadata = nostr::Metadata::from_json(new_metadata_event.content.clone())
221 .context("metadata cannot be found in kind 0 event content")?;
222 user_ref.metadata = UserMetadata {
223 name: if let Some(n) = metadata.name {
224 n
225 } else if let Some(n) = metadata.custom.get("displayName") {
226 // strip quote marks that custom.get() adds
227 let binding = n.to_string();
228 let mut chars = binding.chars();
229 chars.next();
230 chars.next_back();
231 chars.as_str().to_string()
232 } else if let Some(n) = metadata.display_name {
233 n
234 } else {
235 user_ref.metadata.name
236 },
237 created_at: new_metadata_event.created_at.as_u64(),
238 };
239 }
240 };
241
242 if let Some(new_relays_event) = events
243 .iter()
244 .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key))
245 .max_by_key(|e| e.created_at)
246 {
247 if new_relays_event.created_at.as_u64() > user_ref.relays.created_at {
248 let new_relay_list = UserRelays {
249 relays: new_relays_event
250 .tags
251 .iter()
252 .filter(|t| {
253 t.kind().eq(&nostr::TagKind::SingleLetter(
254 SingleLetterTag::lowercase(Alphabet::R),
255 ))
256 })
257 .map(|t| UserRelayRef {
258 url: t.as_vec()[1].clone(),
259 read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"),
260 write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"),
261 })
262 .collect(),
263 created_at: new_relays_event.created_at.as_u64(),
264 };
265 let new_relays: Vec<String> = new_relay_list
266 .write()
267 .iter()
268 .filter(|r| !relays_searched.iter().any(|or| r.eq(&or)))
269 .map(std::clone::Clone::clone)
270 .collect();
271 user_ref.relays = new_relay_list;
272
273 if !new_relays.is_empty() {
274 relays_to_search = new_relays;
275 continue;
276 }
277 }
278 };
279
280 // remove any duplicate entries for key before adding it to config
281 let mut cfg = self.config_manager.load().context("failed to load application config to find and remove any old versions of the user's encrypted key")?;
282 cfg.users = cfg
283 .users
284 .clone()
285 .into_iter()
286 .filter(|r| !r.public_key.eq(public_key))
287 .collect();
288 cfg.users.push(user_ref.clone());
289 self.config_manager
290 .save(&cfg)
291 .context("failed to save application configuration with new user details in")?;
292 break;
293 }
294 Ok(user_ref)
295 }
296}
297
298fn unix_timestamp_after_now_plus_secs(timestamp: u64, secs: u64) -> bool {
299 if let Ok(now) = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
300 now.as_secs() > (timestamp + secs)
301 } else {
302 true
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use nostr;
309 use test_utils::*;
310
311 use super::*;
312 use crate::{
313 cli_interactor::MockInteractorPrompt,
314 config::{MockConfigManagement, MyConfig, UserRef},
315 key_handling::encryption::MockEncryptDecrypt,
316 };
317
318 #[derive(Default)]
319 pub struct MockUserManager {
320 pub config_manager: MockConfigManagement,
321 pub interactor: MockInteractorPrompt,
322 pub encryptor: MockEncryptDecrypt,
323 }
324
325 mod add {
326 use super::*;
327
328 impl MockUserManager {
329 fn add_return_expected_responses(mut self) -> Self {
330 self.config_manager
331 .expect_load()
332 .returning(|| Ok(MyConfig::default()));
333 self.config_manager.expect_save().returning(|_| Ok(()));
334 self.interactor
335 .expect_input()
336 .returning(|_| Ok(TEST_KEY_1_NSEC.into()));
337 self.interactor
338 .expect_password()
339 .returning(|_| Ok(TEST_PASSWORD.into()));
340 self.encryptor
341 .expect_encrypt_key()
342 .returning(|_, _| Ok(TEST_KEY_1_ENCRYPTED.into()));
343 self
344 }
345 }
346
347 fn reuable_user_isnt_prompted(nsec: &str) {
348 let mut m = MockUserManager::default().add_return_expected_responses();
349 m.interactor = MockInteractorPrompt::default();
350 m.interactor.expect_input().never();
351 m.interactor.expect_password().never();
352 let _ = m.add(&Some(nsec.into()), &Some(TEST_PASSWORD.to_string()));
353 }
354
355 fn reuable_config_isnt_modified(nsec: &str) {
356 let mut m = MockUserManager::default();
357 m.config_manager.expect_save().never();
358 let _ = m.add(&Some(nsec.into()), &Some(TEST_PASSWORD.to_string()));
359 }
360
361 mod when_valid_nsec_and_password_is_passed {
362 use super::*;
363
364 #[test]
365 fn user_isnt_prompted() {
366 reuable_user_isnt_prompted(TEST_KEY_1_NSEC);
367 }
368
369 #[test]
370 fn results_in_correct_keys() {
371 let mut m = MockUserManager::default().add_return_expected_responses();
372 m.interactor = MockInteractorPrompt::default();
373 m.interactor.expect_input().never();
374 m.interactor.expect_password().never();
375 let r = m.add(
376 &Some(TEST_KEY_1_NSEC.into()),
377 &Some(TEST_PASSWORD.to_string()),
378 );
379 assert!(r.is_ok(), "should result in keys");
380 assert!(
381 r.is_ok_and(|k| k
382 .secret_key()
383 .is_ok_and(|k| k.display_secret().to_string().eq(TEST_KEY_1_SK_HEX))),
384 "keys should reflect nsec"
385 );
386 }
387 }
388 mod when_invalid_nsec_is_passed_with_password {
389 use super::*;
390
391 #[test]
392 fn user_isnt_prompted() {
393 reuable_user_isnt_prompted(TEST_INVALID_NSEC);
394 }
395
396 #[test]
397 fn config_isnt_modified() {
398 reuable_config_isnt_modified(TEST_INVALID_NSEC);
399 }
400
401 #[test]
402 fn results_in_an_error() {
403 let m = MockUserManager::default();
404 assert!(
405 m.add(
406 &Some(TEST_INVALID_NSEC.into()),
407 &Some(TEST_PASSWORD.to_string())
408 )
409 .is_err(),
410 "should result in an error"
411 );
412 }
413 }
414 mod when_no_nsec_is_passed {
415 use super::*;
416
417 #[test]
418 fn prompt_for_nsec_and_password() {
419 let mut m = MockUserManager::default().add_return_expected_responses();
420
421 m.interactor = MockInteractorPrompt::new();
422 m.interactor
423 .expect_input()
424 .once()
425 .withf(|p| p.prompt.eq("login with nsec (or hex private key)"))
426 .returning(|_| Ok(TEST_KEY_1_NSEC.into()));
427 m.interactor
428 .expect_password()
429 .once()
430 .withf(|p| p.prompt.eq("encrypt with password"))
431 .returning(|_| Ok(TEST_KEY_1_NSEC.into()));
432
433 let _ = m.add(&None, &None);
434 }
435
436 #[test]
437 fn results_in_correct_keys() {
438 let m = MockUserManager::default().add_return_expected_responses();
439
440 let r = m.add(&None, &None);
441 assert!(r.is_ok(), "should result in keys");
442 assert!(
443 r.is_ok_and(|k| k
444 .secret_key()
445 .is_ok_and(|k| k.display_secret().to_string().eq(TEST_KEY_1_SK_HEX))),
446 "keys should reflect nsec"
447 );
448 }
449
450 #[test]
451 fn stores_encrypted_key_in_config() {
452 let mut m = MockUserManager::default().add_return_expected_responses();
453
454 m.config_manager = MockConfigManagement::new();
455 m.config_manager
456 .expect_load()
457 .returning(|| Ok(MyConfig::default()));
458 m.config_manager
459 .expect_save()
460 .withf(|cfg| {
461 cfg.users.len().eq(&1)
462 && cfg.users[0].encrypted_key.eq(TEST_KEY_1_ENCRYPTED)
463 })
464 .returning(|_| Ok(()));
465
466 let _ = m.add(&None, &None);
467 }
468
469 #[test]
470 fn stored_key_encrypted_with_password() {
471 let mut m = MockUserManager::default().add_return_expected_responses();
472
473 m.encryptor = MockEncryptDecrypt::new();
474 m.encryptor
475 .expect_encrypt_key()
476 .once()
477 .withf(|k, p| {
478 k.eq(&Keys::from_str(TEST_KEY_1_NSEC).unwrap()) && p.eq(TEST_PASSWORD)
479 })
480 .returning(|_, _| Ok(TEST_KEY_1_ENCRYPTED.into()));
481
482 let _ = m.add(&None, &None);
483 }
484
485 mod when_user_key_already_stored {
486 use super::*;
487 use crate::config::UserRef;
488
489 /// key overwritten as password may have changed
490 #[test]
491 fn key_not_saved_as_duplicate_but_encrypted_key_overwritten() {
492 let mut m = MockUserManager::default().add_return_expected_responses();
493
494 m.config_manager = MockConfigManagement::default();
495 m.config_manager.expect_load().returning(|| {
496 Ok(MyConfig {
497 users: vec![UserRef::new(
498 TEST_KEY_1_KEYS.public_key(),
499 TEST_KEY_2_ENCRYPTED.into(),
500 )],
501 ..MyConfig::default()
502 })
503 });
504 m.config_manager
505 .expect_save()
506 .withf(|cfg| {
507 cfg.users.len() == 1
508 && cfg.users[0].encrypted_key.eq(TEST_KEY_1_ENCRYPTED)
509 })
510 .returning(|_| Ok(()));
511
512 let _ = m.add(&None, &None);
513 }
514 }
515
516 mod when_multiple_users_added {
517 use super::*;
518
519 #[test]
520 fn both_user_keys_are_stored() {
521 let mut m = MockUserManager::default().add_return_expected_responses();
522
523 m.config_manager = MockConfigManagement::default();
524 m.config_manager.expect_load().returning(|| {
525 Ok(MyConfig {
526 users: vec![UserRef::new(
527 TEST_KEY_2_KEYS.public_key(),
528 TEST_KEY_2_ENCRYPTED.into(),
529 )],
530 ..MyConfig::default()
531 })
532 });
533 m.config_manager
534 .expect_save()
535 .withf(|cfg| {
536 cfg.users.len() == 2
537 // latest user stored at end of array
538 && cfg.users[1].encrypted_key.eq(TEST_KEY_1_ENCRYPTED)
539 })
540 .returning(|_| Ok(()));
541
542 let _ = m.add(&None, &None);
543 }
544 }
545 }
546 }
547
548 fn now_timestamp() -> u64 {
549 SystemTime::now()
550 .duration_since(SystemTime::UNIX_EPOCH)
551 .unwrap()
552 .as_secs()
553 }
554 fn roughly_now(timestamp: u64) -> bool {
555 let now = now_timestamp();
556 timestamp < now + 100 && timestamp > now - 100
557 }
558
559 mod get_user {
560 use anyhow::anyhow;
561
562 use super::*;
563 use crate::client::MockConnect;
564
565 fn generate_relaylist_event() -> nostr::Event {
566 nostr::event::EventBuilder::new(
567 nostr::Kind::RelayList,
568 "",
569 [
570 nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata {
571 relay_url: nostr::Url::from_str("wss://fredswrite1.relay/").unwrap(),
572 metadata: Some(RelayMetadata::Write),
573 }),
574 nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata {
575 relay_url: nostr::Url::from_str("wss://fredsread1.relay/").unwrap(),
576 metadata: Some(RelayMetadata::Read),
577 }),
578 nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata {
579 relay_url: nostr::Url::from_str("wss://fredsreadwrite.relay/").unwrap(),
580 metadata: None,
581 }),
582 ],
583 )
584 .to_event(&TEST_KEY_1_KEYS)
585 .unwrap()
586 }
587
588 fn generate_relaylist_event_user_2() -> nostr::Event {
589 nostr::event::EventBuilder::new(
590 nostr::Kind::RelayList,
591 "",
592 [
593 nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata {
594 relay_url: nostr::Url::from_str("wss://carolswrite1.relay/").unwrap(),
595 metadata: Some(RelayMetadata::Write),
596 }),
597 nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata {
598 relay_url: nostr::Url::from_str("wss://carolsread1.relay/").unwrap(),
599 metadata: Some(RelayMetadata::Read),
600 }),
601 nostr::Tag::from_standardized(nostr::TagStandard::RelayMetadata {
602 relay_url: nostr::Url::from_str("wss://carolsreadwrite.relay/").unwrap(),
603 metadata: None,
604 }),
605 ],
606 )
607 .to_event(&TEST_KEY_2_KEYS)
608 .unwrap()
609 }
610
611 fn fallback_relays() -> Vec<String> {
612 vec!["ws://fallback1".to_string(), "ws://fallback2".to_string()].clone()
613 }
614
615 fn generate_mock_client() -> MockConnect {
616 let mut client = <MockConnect as std::default::Default>::default();
617 client
618 .expect_get_fallback_relays()
619 .return_const(fallback_relays());
620 client
621 }
622
623 fn generate_standard_config() -> MyConfig {
624 MyConfig {
625 users: vec![UserRef {
626 public_key: TEST_KEY_1_KEYS.public_key(),
627 encrypted_key: TEST_KEY_1_ENCRYPTED.to_string(),
628 metadata: UserMetadata {
629 name: "Fred".to_string(),
630 created_at: 10,
631 },
632 relays: UserRelays {
633 relays: vec![
634 UserRelayRef {
635 url: "ws://existingread".to_string(),
636 read: true,
637 write: false,
638 },
639 UserRelayRef {
640 url: "ws://existingreadwrite".to_string(),
641 read: true,
642 write: true,
643 },
644 UserRelayRef {
645 url: "ws://existingwrite".to_string(),
646 read: false,
647 write: true,
648 },
649 ],
650 created_at: 10,
651 },
652 last_checked: now_timestamp() - (60 * 60), // 1h ago
653 }],
654 ..MyConfig::default()
655 }
656 .clone()
657 }
658
659 fn expected_userrelayrefs_write1() -> UserRelayRef {
660 UserRelayRef {
661 url: "wss://fredswrite1.relay/".into(),
662 read: false,
663 write: true,
664 }
665 .clone()
666 }
667
668 fn expected_userrelayrefs_read_write1() -> UserRelayRef {
669 UserRelayRef {
670 url: "wss://fredsreadwrite.relay/".into(),
671 read: true,
672 write: true,
673 }
674 .clone()
675 }
676
677 fn expected_userrelayrefs() -> Vec<UserRelayRef> {
678 vec![
679 expected_userrelayrefs_write1(),
680 UserRelayRef {
681 url: "wss://fredsread1.relay/".into(),
682 read: true,
683 write: false,
684 },
685 expected_userrelayrefs_read_write1(),
686 ]
687 }
688
689 mod when_within_caching_time_window {
690 use super::*;
691
692 #[tokio::test]
693 async fn returns_cached_details_without_checking_relays_or_updaing_config() -> Result<()>
694 {
695 let mut m = MockUserManager::default();
696 let client = generate_mock_client();
697 m.config_manager
698 .expect_load()
699 .returning(|| Ok(generate_standard_config()));
700 let res = m
701 .get_user(
702 &client,
703 &TEST_KEY_1_KEYS.public_key(),
704 24 * 60 * 60, // within 24 hours
705 )
706 .await?;
707 assert_eq!(res.metadata.name, "Fred");
708 assert_eq!(res.relays.relays[0].url, "ws://existingread");
709 Ok(())
710 }
711 }
712
713 mod returns_userref_with_latest_details_from_events_on_relays {
714 use super::*;
715
716 #[tokio::test]
717 async fn name() -> Result<()> {
718 let mut m = MockUserManager::default();
719 let mut client = generate_mock_client();
720 m.config_manager
721 .expect_load()
722 .returning(|| Ok(generate_standard_config()));
723 m.config_manager.expect_save().returning(|_| Ok(()));
724 client
725 .expect_get_events()
726 .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")]));
727
728 let res = m
729 .get_user(
730 &client,
731 &TEST_KEY_1_KEYS.public_key(),
732 5 * 60, // 5 mins ago
733 )
734 .await?;
735 assert_eq!(res.metadata.name, "fred");
736 Ok(())
737 }
738
739 #[tokio::test]
740 async fn name_ignoring_other_users_events() -> Result<()> {
741 let mut m = MockUserManager::default();
742 let mut client = generate_mock_client();
743 m.config_manager
744 .expect_load()
745 .returning(|| Ok(generate_standard_config()));
746 m.config_manager.expect_save().returning(|_| Ok(()));
747 client.expect_get_events().returning(|_, _| {
748 Ok(vec![
749 generate_test_key_2_metadata_event("carole"),
750 generate_test_key_1_metadata_event_old("fred"),
751 ])
752 });
753
754 let res = m
755 .get_user(
756 &client,
757 &TEST_KEY_1_KEYS.public_key(),
758 5 * 60, // 5 mins ago
759 )
760 .await?;
761 assert_eq!(res.metadata.name, "fred");
762 Ok(())
763 }
764
765 #[tokio::test]
766 async fn relays() -> Result<()> {
767 let mut m = MockUserManager::default();
768 let mut client = generate_mock_client();
769 m.config_manager
770 .expect_load()
771 .returning(|| Ok(generate_standard_config()));
772 m.config_manager.expect_save().returning(|_| Ok(()));
773 client.expect_get_events().returning(|_, _| {
774 Ok(vec![
775 generate_test_key_1_metadata_event("fred"),
776 generate_relaylist_event(),
777 ])
778 });
779
780 let res = m
781 .get_user(
782 &client,
783 &TEST_KEY_1_KEYS.public_key(),
784 5 * 60, // 5 mins ago
785 )
786 .await?;
787 assert_eq!(res.relays.relays, expected_userrelayrefs(),);
788 Ok(())
789 }
790
791 #[tokio::test]
792 async fn relays_ignoring_other_users_events() -> Result<()> {
793 let mut m = MockUserManager::default();
794 let mut client = generate_mock_client();
795 m.config_manager
796 .expect_load()
797 .returning(|| Ok(generate_standard_config()));
798 m.config_manager.expect_save().returning(|_| Ok(()));
799 client.expect_get_events().returning(|_, _| {
800 Ok(vec![
801 make_event_old_or_change_user(
802 generate_relaylist_event(),
803 &TEST_KEY_1_KEYS,
804 10000,
805 ),
806 generate_relaylist_event_user_2(),
807 ])
808 });
809
810 let res = m
811 .get_user(
812 &client,
813 &TEST_KEY_1_KEYS.public_key(),
814 5 * 60, // 5 mins ago
815 )
816 .await?;
817 assert_eq!(res.relays.relays, expected_userrelayrefs(),);
818 Ok(())
819 }
820 }
821
822 mod saves_updates_to_config {
823 use super::*;
824
825 #[tokio::test]
826 async fn saves_name_to_config() -> Result<()> {
827 let mut m = MockUserManager::default();
828 let mut client = generate_mock_client();
829 m.config_manager
830 .expect_load()
831 .returning(|| Ok(generate_standard_config()));
832 m.config_manager
833 .expect_save()
834 .once()
835 .withf(|cfg| cfg.users[0].metadata.name.eq("fred"))
836 .returning(|_| Ok(()));
837 client
838 .expect_get_events()
839 .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")]));
840
841 let _ = m
842 .get_user(
843 &client,
844 &TEST_KEY_1_KEYS.public_key(),
845 5 * 60, // 5 mins ago
846 )
847 .await?;
848 Ok(())
849 }
850
851 #[tokio::test]
852 async fn updates_metadata_created_at() -> Result<()> {
853 let mut m = MockUserManager::default();
854 let mut client = generate_mock_client();
855 m.config_manager
856 .expect_load()
857 .returning(|| Ok(generate_standard_config()));
858 m.config_manager
859 .expect_save()
860 .once()
861 .withf(|cfg| roughly_now(cfg.users[0].metadata.created_at))
862 .returning(|_| Ok(()));
863 client
864 .expect_get_events()
865 .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")]));
866
867 let _ = m
868 .get_user(
869 &client,
870 &TEST_KEY_1_KEYS.public_key(),
871 5 * 60, // 5 mins ago
872 )
873 .await?;
874 Ok(())
875 }
876
877 #[tokio::test]
878 async fn saves_relays_to_config() -> Result<()> {
879 let mut m = MockUserManager::default();
880 let mut client = generate_mock_client();
881 m.config_manager
882 .expect_load()
883 .returning(|| Ok(generate_standard_config()));
884 m.config_manager
885 .expect_save()
886 .once()
887 .withf(|cfg| expected_userrelayrefs().eq(&cfg.users[0].relays.relays))
888 .returning(|_| Ok(()));
889 client
890 .expect_get_events()
891 .returning(|_, _| Ok(vec![generate_relaylist_event()]));
892
893 let _ = m
894 .get_user(
895 &client,
896 &TEST_KEY_1_KEYS.public_key(),
897 5 * 60, // 5 mins ago
898 )
899 .await?;
900 Ok(())
901 }
902
903 #[tokio::test]
904 async fn updates_relays_created_at() -> Result<()> {
905 let mut m = MockUserManager::default();
906 let mut client = generate_mock_client();
907 m.config_manager
908 .expect_load()
909 .returning(|| Ok(generate_standard_config()));
910 m.config_manager
911 .expect_save()
912 .once()
913 .withf(|cfg| roughly_now(cfg.users[0].relays.created_at))
914 .returning(|_| Ok(()));
915 client
916 .expect_get_events()
917 .returning(|_, _| Ok(vec![generate_relaylist_event()]));
918
919 let _ = m
920 .get_user(
921 &client,
922 &TEST_KEY_1_KEYS.public_key(),
923 5 * 60, // 5 mins ago
924 )
925 .await?;
926 Ok(())
927 }
928
929 #[tokio::test]
930 async fn when_no_changes_updates_last_updated() -> Result<()> {
931 let mut m = MockUserManager::default();
932 let mut client = generate_mock_client();
933 m.config_manager
934 .expect_load()
935 .returning(|| Ok(generate_standard_config()));
936 m.config_manager
937 .expect_save()
938 .once()
939 .withf(|cfg| roughly_now(cfg.users[0].last_checked))
940 .returning(|_| Ok(()));
941 client.expect_get_events().returning(|_, _| Ok(vec![]));
942
943 let _ = m
944 .get_user(
945 &client,
946 &TEST_KEY_1_KEYS.public_key(),
947 5 * 60, // 5 mins ago
948 )
949 .await?;
950 Ok(())
951 }
952
953 #[tokio::test]
954 async fn when_changes_updates_last_updated() -> Result<()> {
955 let mut m = MockUserManager::default();
956 let mut client = generate_mock_client();
957 m.config_manager
958 .expect_load()
959 .returning(|| Ok(generate_standard_config()));
960 m.config_manager
961 .expect_save()
962 .once()
963 .withf(|cfg| roughly_now(cfg.users[0].last_checked))
964 .returning(|_| Ok(()));
965 client
966 .expect_get_events()
967 .returning(|_, _| Ok(vec![generate_test_key_1_metadata_event("fred")]));
968
969 let _ = m
970 .get_user(
971 &client,
972 &TEST_KEY_1_KEYS.public_key(),
973 5 * 60, // 5 mins ago
974 )
975 .await?;
976 Ok(())
977 }
978 }
979
980 mod fetches_from_correct_relays {
981 use super::*;
982 #[tokio::test]
983 async fn when_userref_write_relays_present_fetches_only_from_them() -> Result<()> {
984 let mut m = MockUserManager::default();
985 let mut client = generate_mock_client();
986 m.config_manager
987 .expect_load()
988 .returning(|| Ok(generate_standard_config()));
989 m.config_manager.expect_save().returning(|_| Ok(()));
990 client
991 .expect_get_events()
992 .once()
993 .withf(move |relays, _filters| {
994 vec![
995 "ws://existingreadwrite".to_string(),
996 "ws://existingwrite".to_string(),
997 ]
998 .eq(relays)
999 })
1000 .returning(|_, _| Ok(vec![]));
1001
1002 let _ = m
1003 .get_user(
1004 &client,
1005 &TEST_KEY_1_KEYS.public_key(),
1006 5 * 60, // 5 mins ago
1007 )
1008 .await?;
1009 Ok(())
1010 }
1011
1012 #[tokio::test]
1013 async fn when_userref_write_relays_not_present_fetches_from_fallback_relays()
1014 -> Result<()> {
1015 let mut m = MockUserManager::default();
1016 let mut client = generate_mock_client();
1017 m.config_manager.expect_load().returning(|| {
1018 Ok(MyConfig {
1019 users: vec![UserRef {
1020 relays: UserRelays {
1021 relays: vec![],
1022 created_at: 0,
1023 },
1024 ..generate_standard_config().users[0].clone()
1025 }],
1026 ..generate_standard_config()
1027 })
1028 });
1029 m.config_manager.expect_save().returning(|_| Ok(()));
1030 client
1031 .expect_get_events()
1032 .once()
1033 .withf(move |relays, _filters| fallback_relays().eq(relays))
1034 .returning(|_, _| Ok(vec![]));
1035
1036 let _ = m
1037 .get_user(
1038 &client,
1039 &TEST_KEY_1_KEYS.public_key(),
1040 5 * 60, // 5 mins ago
1041 )
1042 .await?;
1043 Ok(())
1044 }
1045
1046 mod fetches_from_new_relays_discovered_in_incoming_relay_list {
1047 use super::*;
1048
1049 #[tokio::test]
1050 async fn when_all_relays_in_list_are_new_finds_name() -> Result<()> {
1051 let mut m = MockUserManager::default();
1052 let mut client = generate_mock_client();
1053 m.config_manager.expect_load().returning(|| {
1054 Ok(MyConfig {
1055 users: vec![UserRef {
1056 relays: UserRelays {
1057 relays: vec![],
1058 created_at: 0,
1059 },
1060 ..generate_standard_config().users[0].clone()
1061 }],
1062 ..generate_standard_config()
1063 })
1064 });
1065 m.config_manager.expect_save().returning(|_| Ok(()));
1066 client
1067 .expect_get_events()
1068 .times(2)
1069 .withf(move |relays, _filters| {
1070 fallback_relays().eq(relays)
1071 || UserRelays {
1072 relays: expected_userrelayrefs(),
1073 created_at: 0,
1074 }
1075 .write()
1076 .eq(relays)
1077 })
1078 .returning(|relays, _| {
1079 if fallback_relays().eq(&relays) {
1080 Ok(vec![generate_relaylist_event()])
1081 } else if (UserRelays {
1082 relays: expected_userrelayrefs(),
1083 created_at: 0,
1084 })
1085 .write()
1086 .eq(&relays)
1087 {
1088 Ok(vec![generate_test_key_1_metadata_event("fred")])
1089 } else {
1090 Ok(vec![])
1091 }
1092 });
1093
1094 let res = m
1095 .get_user(
1096 &client,
1097 &TEST_KEY_1_KEYS.public_key(),
1098 5 * 60, // 5 mins ago
1099 )
1100 .await?;
1101 assert_eq!(res.metadata.name, "fred");
1102 Ok(())
1103 }
1104
1105 #[tokio::test]
1106 async fn only_fetches_from_newly_added_relays() -> Result<()> {
1107 let mut m = MockUserManager::default();
1108 let mut client = generate_mock_client();
1109 m.config_manager.expect_load().returning(|| {
1110 Ok(MyConfig {
1111 users: vec![UserRef {
1112 relays: UserRelays {
1113 relays: vec![expected_userrelayrefs_write1()],
1114 created_at: 0,
1115 },
1116 ..generate_standard_config().users[0].clone()
1117 }],
1118 ..generate_standard_config()
1119 })
1120 });
1121 m.config_manager.expect_save().returning(|_| Ok(()));
1122 client
1123 .expect_get_events()
1124 .times(2)
1125 .withf(move |relays, _filters| {
1126 vec![expected_userrelayrefs_write1().url].eq(relays)
1127 || vec![expected_userrelayrefs_read_write1().url].eq(relays)
1128 })
1129 .returning(|relays, _| {
1130 if vec![expected_userrelayrefs_write1().url].eq(&relays) {
1131 Ok(vec![generate_relaylist_event()])
1132 } else if vec![expected_userrelayrefs_read_write1().url].eq(&relays) {
1133 Ok(vec![generate_test_key_1_metadata_event("fred")])
1134 } else {
1135 Ok(vec![])
1136 }
1137 });
1138
1139 let res = m
1140 .get_user(
1141 &client,
1142 &TEST_KEY_1_KEYS.public_key(),
1143 5 * 60, // 5 mins ago
1144 )
1145 .await?;
1146 assert_eq!(res.metadata.name, "fred");
1147 Ok(())
1148 }
1149 }
1150 }
1151
1152 #[tokio::test]
1153 async fn when_failed_to_fetch_events_returns_cached_details() -> Result<()> {
1154 let mut m = MockUserManager::default();
1155 let mut client = generate_mock_client();
1156 m.config_manager
1157 .expect_load()
1158 .returning(|| Ok(generate_standard_config()));
1159 client
1160 .expect_get_events()
1161 .returning(|_, _| Err(anyhow!("test error")));
1162
1163 let res = m
1164 .get_user(
1165 &client,
1166 &TEST_KEY_1_KEYS.public_key(),
1167 5 * 60, // 10 mins ago
1168 )
1169 .await?;
1170 assert_eq!(res.metadata.name, "Fred");
1171 Ok(())
1172 }
1173 }
1174}
diff --git a/src/login.rs b/src/login.rs
index 4cdf3c1..58d1b87 100644
--- a/src/login.rs
+++ b/src/login.rs
@@ -2,130 +2,407 @@ use std::str::FromStr;
2 2
3use anyhow::{bail, Context, Result}; 3use anyhow::{bail, Context, Result};
4use nostr::PublicKey; 4use nostr::PublicKey;
5use zeroize::Zeroize; 5use nostr_database::Order;
6use nostr_sdk::{Alphabet, FromBech32, JsonUtil, Kind, NostrDatabase, SingleLetterTag, ToBech32};
7use nostr_sqlite::SQLiteDatabase;
6 8
7#[cfg(not(test))] 9#[cfg(not(test))]
8use crate::client::Client; 10use crate::client::Client;
9#[cfg(test)] 11#[cfg(test)]
10use crate::client::MockConnect; 12use crate::client::MockConnect;
11use crate::{ 13use crate::{
12 cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, 14 cli_interactor::{
13 config::{ConfigManagement, ConfigManager, UserRef}, 15 Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms,
14 key_handling::{
15 encryption::{EncryptDecrypt, Encryptor},
16 users::{UserManagement, UserManager},
17 }, 16 },
17 client::Connect,
18 config::{get_dirs, UserMetadata, UserRef, UserRelayRef, UserRelays},
19 git::{Repo, RepoActions},
20 key_handling::encryption::{decrypt_key, encrypt_key},
18}; 21};
19 22
20/// handles the encrpytion and storage of key material 23/// handles the encrpytion and storage of key material
21pub async fn launch( 24pub async fn launch(
25 git_repo: &Repo,
22 nsec: &Option<String>, 26 nsec: &Option<String>,
23 password: &Option<String>, 27 password: &Option<String>,
24 #[cfg(test)] client: Option<&MockConnect>, 28 #[cfg(test)] client: Option<&MockConnect>,
25 #[cfg(not(test))] client: Option<&Client>, 29 #[cfg(not(test))] client: Option<&Client>,
30 change_user: bool,
26) -> Result<(nostr::Keys, UserRef)> { 31) -> Result<(nostr::Keys, UserRef)> {
27 // if nsec parameter 32 if let Ok(keys) = match get_keys_without_prompts(git_repo, nsec, password, change_user) {
28 let key = if let Some(nsec_unwrapped) = nsec { 33 Ok(keys) => Ok(keys),
29 // get key or fail without prompts 34 Err(error) => {
30 let key = nostr::Keys::from_str(nsec_unwrapped).context("invalid nsec parameter")?; 35 if error
31 36 .to_string()
32 // if password, add user to enable password login in future 37 .eq("git config item nostr.nsec is an ncryptsec")
33 if password.is_some() { 38 {
34 UserManager::default() 39 println!(
35 .add(nsec, password) 40 "login as {}",
36 .context("could not store identity")?; 41 if let Ok(public_key) = PublicKey::from_bech32(
37 } else { 42 get_config_item(git_repo, "nostr.npub")
38 UserManager::default().add_user_to_config(key.public_key(), None, false)?; 43 .unwrap_or("unknown ncryptsec".to_string()),
44 ) {
45 if let Ok(user_ref) = get_user_details(&public_key, client).await {
46 user_ref.metadata.name
47 } else {
48 "unknown ncryptsec".to_string()
49 }
50 } else {
51 "unknown ncryptsec".to_string()
52 }
53 );
54 loop {
55 // prompt for password
56 let password = Interactor::default()
57 .password(PromptPasswordParms::default().with_prompt("password"))
58 .context("failed to get password input from interactor.password")?;
59 if let Ok(keys) = get_keys_with_password(git_repo, &password) {
60 break Ok(keys);
61 }
62 println!("incorrect password");
63 }
64 } else {
65 if nsec.is_some() {
66 bail!(error);
67 }
68 Err(error)
69 }
39 } 70 }
40 key 71 } {
72 // get user ref
73 let user_ref = get_user_details(&keys.public_key(), client).await?;
74 print_logged_in_as(&user_ref, client.is_none())?;
75 Ok((keys, user_ref))
41 } else { 76 } else {
42 let cfg = ConfigManager 77 fresh_login(git_repo, client, change_user).await
43 .load() 78 }
44 .context("failed to load application config")?; 79}
45 // if encrypted nsec present 80
46 if cfg.users.last().is_some() && !cfg.users.last().unwrap().encrypted_key.is_empty() { 81fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> {
47 // unfortunately this line is unstable in rust: 82 if !offline_mode && user_ref.metadata.created_at.eq(&0) {
48 // if let Some(user) = cfg.users.last() && !user.encrypted_key.is_empty() { 83 println!("cannot find your account metadata (name, etc) on relays");
49 let user = cfg.users.last().unwrap(); 84 } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) {
50 let mut pass = if let Some(p) = password.clone() { 85 println!("cannot extract account name from account metadata...");
51 p 86 } else if !offline_mode && user_ref.relays.created_at.eq(&0) {
52 } else { 87 println!(
53 println!("login as {}", &user.metadata.name); 88 "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience."
54 Interactor::default() 89 );
55 .password(PromptPasswordParms::default().with_prompt("password")) 90 }
56 .context("failed to get password input from interactor.password")? 91 println!("logged in as {}", user_ref.metadata.name);
57 }; 92 Ok(())
58 93}
59 let key_result = Encryptor 94
60 .decrypt_key(&user.encrypted_key, pass.as_str()) 95fn get_keys_without_prompts(
61 .context("failed to decrypt key with provided password"); 96 git_repo: &Repo,
62 pass.zeroize(); 97 nsec: &Option<String>,
63 98 password: &Option<String>,
64 key_result.context(format!("failed to log in as {}", &user.metadata.name))? 99 save_local: bool,
100) -> Result<nostr::Keys> {
101 if let Some(nsec) = nsec {
102 get_keys_from_nsec(git_repo, nsec, password, save_local)
103 } else if let Some(password) = password {
104 get_keys_with_password(git_repo, password)
105 } else if !save_local {
106 get_keys_with_git_config_nsec_without_prompts(git_repo)
107 } else {
108 bail!("user wants prompts to specify new keys")
109 }
110}
111
112fn get_keys_from_nsec(
113 git_repo: &Repo,
114 nsec: &String,
115 password: &Option<String>,
116 save_local: bool,
117) -> Result<nostr::Keys> {
118 #[allow(unused_assignments)]
119 let mut s = String::new();
120 let keys = if nsec.contains("ncryptsec") {
121 s = nsec.to_string();
122 decrypt_key(
123 nsec,
124 password
125 .clone()
126 .context("password must be supplied when using ncryptsec as nsec parameter")?
127 .as_str(),
128 )
129 .context("failed to decrypt key with provided password")
130 .context("failed to decrypt ncryptsec supplied as nsec with password")?
131 } else {
132 s = nsec.to_string();
133 nostr::Keys::from_str(nsec).context("invalid nsec parameter")?
134 };
135 if save_local {
136 if let Some(password) = password {
137 s = encrypt_key(&keys, password)?;
65 } 138 }
66 // no encrypted nsec present 139 git_repo
67 else { 140 .save_git_config_item("nostr.nsec", &s, false)
68 // no nsec but password supplied 141 .context("failed to save encrypted nsec in local git config nostr.nsec")?;
69 if password.is_some() { 142 git_repo.save_git_config_item("nostr.npub", &keys.public_key().to_bech32()?, false)?;
70 bail!("no nsec available to decrypt with specified password"); 143 }
144 Ok(keys)
145}
146
147fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result<nostr::Keys> {
148 decrypt_key(
149 &git_repo
150 .get_git_config_item("nostr.nsec", false)
151 .context("failed get git config")?
152 .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?,
153 password,
154 )
155 .context("failed to decrypt stored nsec key with provided password")
156}
157
158fn get_keys_with_git_config_nsec_without_prompts(git_repo: &Repo) -> Result<nostr::Keys> {
159 let nsec = &git_repo
160 .get_git_config_item("nostr.nsec", false)
161 .context("failed get git config")?
162 .context("git config item nostr.nsec doesn't exist")?;
163 if nsec.contains("ncryptsec") {
164 bail!("git config item nostr.nsec is an ncryptsec")
165 }
166 nostr::Keys::from_str(nsec).context("invalid nsec parameter")
167}
168
169async fn fresh_login(
170 git_repo: &Repo,
171 #[cfg(test)] client: Option<&MockConnect>,
172 #[cfg(not(test))] client: Option<&Client>,
173 always_save: bool,
174) -> Result<(nostr::Keys, UserRef)> {
175 // prompt for nsec
176 let mut prompt = "login with nsec";
177 let keys = loop {
178 match nostr::Keys::from_str(
179 &Interactor::default()
180 .input(PromptInputParms::default().with_prompt(prompt))
181 .context("failed to get nsec input from interactor")?,
182 ) {
183 Ok(key) => {
184 break key;
185 }
186 Err(_) => {
187 prompt = "invalid nsec. try again with nsec (or hex private key)";
71 } 188 }
72 // otherwise add new user with nsec and password prompts
73 UserManager::default()
74 .add(nsec, password)
75 .context("failed to add user")?
76 } 189 }
77 }; 190 };
191 // lookup profile
192 // save keys
193 if let Err(error) = save_keys(git_repo, &keys, always_save) {
194 println!("{error}");
195 }
196 let user_ref = get_user_details(&keys.public_key(), client).await?;
197 print_logged_in_as(&user_ref, client.is_none())?;
198 Ok((keys, user_ref))
199}
200
201fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> {
202 let store = always_save
203 || Interactor::default()
204 .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?;
205
206 let global = !Interactor::default().confirm(
207 PromptConfirmParms::default()
208 .with_prompt("just for this repository?")
209 .with_default(false),
210 )?;
211
212 let encrypt = Interactor::default().confirm(
213 PromptConfirmParms::default()
214 .with_prompt("require password?")
215 .with_default(false),
216 )?;
78 217
79 // get user details 218 if store {
80 let user_ref = if let Some(client) = client { 219 let npub = keys.public_key().to_bech32()?;
81 get_user_details(&key.public_key(), client).await? 220 let nsec_string = if encrypt {
221 let password = Interactor::default()
222 .password(
223 PromptPasswordParms::default()
224 .with_prompt("encrypt with password")
225 .with_confirm(),
226 )
227 .context("failed to get password input from interactor.password")?;
228 encrypt_key(keys, &password)?
229 } else {
230 keys.secret_key()?.to_bech32()?
231 };
232
233 if let Err(error) = git_repo.save_git_config_item("nostr.nsec", &nsec_string, global) {
234 if global {
235 println!("failed to edit global git config instead");
236 if Interactor::default().confirm(
237 PromptConfirmParms::default()
238 .with_prompt("save in repository git config?")
239 .with_default(true),
240 )? {
241 git_repo.save_git_config_item("nostr.nsec", &nsec_string, false)?;
242 git_repo.save_git_config_item("nostr.npub", &npub, false)?;
243 }
244 } else {
245 bail!(error)
246 }
247 } else {
248 git_repo.save_git_config_item("nostr.npub", &npub, global)?;
249 };
250 };
251 Ok(())
252}
253
254fn get_config_item(git_repo: &Repo, name: &str) -> Result<String> {
255 git_repo
256 .get_git_config_item(name, false)
257 .context("failed get git config")?
258 .context(format!("git config item {name} doesn't exist"))
259}
260
261fn extract_user_metadata(
262 public_key: &nostr::PublicKey,
263 events: &[nostr::Event],
264) -> Result<UserMetadata> {
265 let event = events
266 .iter()
267 .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key))
268 .max_by_key(|e| e.created_at);
269
270 let metadata: Option<nostr::Metadata> = if let Some(event) = event {
271 Some(
272 nostr::Metadata::from_json(event.content.clone())
273 .context("metadata cannot be found in kind 0 event content")?,
274 )
82 } else { 275 } else {
83 // this will get user details with name as npub 276 None
84 UserManager::default()
85 .get_user_from_cache(&key.public_key())?
86 .clone()
87 }; 277 };
88 278
89 // print logged in 279 Ok(UserMetadata {
90 println!("logged in as {}", user_ref.metadata.name); 280 name: if let Some(metadata) = metadata {
281 if let Some(n) = metadata.name {
282 n
283 } else if let Some(n) = metadata.custom.get("displayName") {
284 // strip quote marks that custom.get() adds
285 let binding = n.to_string();
286 let mut chars = binding.chars();
287 chars.next();
288 chars.next_back();
289 chars.as_str().to_string()
290 } else if let Some(n) = metadata.display_name {
291 n
292 } else {
293 public_key.to_bech32()?
294 }
295 } else {
296 public_key.to_bech32()?
297 },
298 created_at: if let Some(event) = event {
299 event.created_at.as_u64()
300 } else {
301 0
302 },
303 })
304}
91 305
92 Ok((key, user_ref.clone())) 306fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays {
307 let event = events
308 .iter()
309 .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key))
310 .max_by_key(|e| e.created_at);
311
312 UserRelays {
313 relays: if let Some(event) = event {
314 event
315 .tags
316 .iter()
317 .filter(|t| {
318 t.kind()
319 .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase(
320 Alphabet::R,
321 )))
322 })
323 .map(|t| UserRelayRef {
324 url: t.as_vec()[1].clone(),
325 read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"),
326 write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"),
327 })
328 .collect()
329 } else {
330 vec![]
331 },
332 created_at: if let Some(event) = event {
333 event.created_at.as_u64()
334 } else {
335 0
336 },
337 }
93} 338}
94 339
95async fn get_user_details( 340async fn get_user_details(
96 public_key: &PublicKey, 341 public_key: &PublicKey,
97 #[cfg(test)] client: &crate::client::MockConnect, 342 #[cfg(test)] client: Option<&crate::client::MockConnect>,
98 #[cfg(not(test))] client: &Client, 343 #[cfg(not(test))] client: Option<&Client>,
99) -> Result<UserRef> { 344) -> Result<UserRef> {
100 let term = console::Term::stdout(); 345 if client.is_some() {
101 term.write_line("searching for profile and relay updates...")?; 346 println!("searching for profile and relay updates...");
102 let user_manager = UserManager::default();
103 let user_ref = user_manager
104 .get_user(
105 client,
106 public_key,
107 // use cache for 3 minutes
108 3 * 60,
109 )
110 .await?;
111 term.clear_last_lines(1)?;
112 if user_ref.metadata.created_at.eq(&0) {
113 println!("cannot find your account metadata (name, etc) on relays",);
114 // TODO use secondary fallback list of relays.
115 // TODO better reporting of what relays were checked and what the user
116 // here is a starter:
117 // cannot find account details on relays:
118 // - purplepages.xyz
119 // - fallbackrelay1
120 // - ...
121 // would you like to:
122 // [-] proceed anyway
123 // - add custom fallback relays
124 } else if user_ref.relays.created_at.eq(&0) {
125 println!(
126 "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience."
127 );
128 // TODO better guidance on how to do this
129 } 347 }
348 let database = SQLiteDatabase::open(get_dirs()?.config_dir().join("cache.sqlite")).await?;
349 let mut events: Vec<nostr::Event> = vec![];
350 let filters = vec![
351 nostr::Filter::default()
352 .author(*public_key)
353 .kind(Kind::Metadata),
354 nostr::Filter::default()
355 .author(*public_key)
356 .kind(Kind::RelayList),
357 ];
358 if let Ok(cached_events) = database.query(filters.clone(), Order::Asc).await {
359 for event in cached_events {
360 events.push(event);
361 }
362 }
363 let mut relays_to_search = if let Some(client) = client {
364 client.get_fallback_relays().clone()
365 } else {
366 vec![]
367 };
368 let mut relays_searched = vec![];
369 let user_ref = loop {
370 if let Some(client) = client {
371 for event in client
372 .get_events(relays_to_search.clone(), filters.clone())
373 .await
374 .unwrap_or(vec![])
375 {
376 let _ = database.save_event(&event).await;
377 events.push(event);
378 }
379 }
380
381 #[allow(clippy::clone_on_copy)]
382 let user_ref = UserRef {
383 public_key: public_key.clone(),
384 metadata: extract_user_metadata(public_key, &events)?,
385 relays: extract_user_relays(public_key, &events),
386 };
387
388 if client.is_none() {
389 break user_ref;
390 }
391 for r in &relays_to_search {
392 relays_searched.push(r.clone());
393 }
394
395 relays_to_search = user_ref
396 .relays
397 .write()
398 .iter()
399 .filter(|r| !relays_searched.iter().any(|or| r.eq(&or)))
400 .map(std::clone::Clone::clone)
401 .collect();
402 if !relays_to_search.is_empty() {
403 continue;
404 }
405 break user_ref;
406 };
130 Ok(user_ref) 407 Ok(user_ref)
131} 408}
diff --git a/src/sub_commands/init.rs b/src/sub_commands/init.rs
index edaca15..4d1bdfb 100644
--- a/src/sub_commands/init.rs
+++ b/src/sub_commands/init.rs
@@ -59,7 +59,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
59 #[cfg(test)] 59 #[cfg(test)]
60 let mut client = <MockConnect as std::default::Default>::default(); 60 let mut client = <MockConnect as std::default::Default>::default();
61 61
62 let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?; 62 let (keys, user_ref) = login::launch(
63 &git_repo,
64 &cli_args.nsec,
65 &cli_args.password,
66 Some(&client),
67 false,
68 )
69 .await?;
63 70
64 client.set_keys(&keys).await; 71 client.set_keys(&keys).await;
65 72
diff --git a/src/sub_commands/login.rs b/src/sub_commands/login.rs
index 43ce480..e71d431 100644
--- a/src/sub_commands/login.rs
+++ b/src/sub_commands/login.rs
@@ -1,11 +1,11 @@
1use anyhow::Result; 1use anyhow::{Context, Result};
2use clap; 2use clap;
3 3
4#[cfg(not(test))] 4#[cfg(not(test))]
5use crate::client::Client; 5use crate::client::Client;
6#[cfg(test)] 6#[cfg(test)]
7use crate::client::MockConnect; 7use crate::client::MockConnect;
8use crate::{client::Connect, login, Cli}; 8use crate::{client::Connect, git::Repo, login, Cli};
9 9
10#[derive(clap::Args)] 10#[derive(clap::Args)]
11pub struct SubCommandArgs { 11pub struct SubCommandArgs {
@@ -15,8 +15,9 @@ pub struct SubCommandArgs {
15} 15}
16 16
17pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> { 17pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
18 let git_repo = Repo::discover().context("cannot find a git repository")?;
18 if command_args.offline { 19 if command_args.offline {
19 login::launch(&args.nsec, &args.password, None).await?; 20 login::launch(&git_repo, &args.nsec, &args.password, None, true).await?;
20 Ok(()) 21 Ok(())
21 } else { 22 } else {
22 #[cfg(not(test))] 23 #[cfg(not(test))]
@@ -24,7 +25,7 @@ pub async fn launch(args: &Cli, command_args: &SubCommandArgs) -> Result<()> {
24 #[cfg(test)] 25 #[cfg(test)]
25 let client = <MockConnect as std::default::Default>::default(); 26 let client = <MockConnect as std::default::Default>::default();
26 27
27 login::launch(&args.nsec, &args.password, Some(&client)).await?; 28 login::launch(&git_repo, &args.nsec, &args.password, Some(&client), true).await?;
28 client.disconnect().await?; 29 client.disconnect().await?;
29 Ok(()) 30 Ok(())
30 } 31 }
diff --git a/src/sub_commands/push.rs b/src/sub_commands/push.rs
index fefe102..ade2ff8 100644
--- a/src/sub_commands/push.rs
+++ b/src/sub_commands/push.rs
@@ -148,7 +148,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
148 ahead.len() 148 ahead.len()
149 ); 149 );
150 150
151 let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?; 151 let (keys, user_ref) = login::launch(
152 &git_repo,
153 &cli_args.nsec,
154 &cli_args.password,
155 Some(&client),
156 false,
157 )
158 .await?;
152 159
153 client.set_keys(&keys).await; 160 client.set_keys(&keys).await;
154 161
diff --git a/src/sub_commands/send.rs b/src/sub_commands/send.rs
index c8d900c..8971d8b 100644
--- a/src/sub_commands/send.rs
+++ b/src/sub_commands/send.rs
@@ -178,7 +178,14 @@ pub async fn launch(cli_args: &Cli, args: &SubCommandArgs) -> Result<()> {
178 } else { 178 } else {
179 None 179 None
180 }; 180 };
181 let (keys, user_ref) = login::launch(&cli_args.nsec, &cli_args.password, Some(&client)).await?; 181 let (keys, user_ref) = login::launch(
182 &git_repo,
183 &cli_args.nsec,
184 &cli_args.password,
185 Some(&client),
186 false,
187 )
188 .await?;
182 189
183 client.set_keys(&keys).await; 190 client.set_keys(&keys).await;
184 191
diff --git a/test_utils/src/lib.rs b/test_utils/src/lib.rs
index ff1f41b..bf6c37b 100644
--- a/test_utils/src/lib.rs
+++ b/test_utils/src/lib.rs
@@ -952,17 +952,20 @@ where
952} 952}
953 953
954fn backup_existing_config() -> Result<()> { 954fn backup_existing_config() -> Result<()> {
955 let config_path = get_dirs().config_dir().join("config.json"); 955 let config_path = get_dirs().config_dir().join("cache.sqlite");
956 let backup_config_path = get_dirs().config_dir().join("config-backup.json"); 956 let backup_config_path = get_dirs().config_dir().join("cache-backup.sqlite");
957 if !backup_config_path.exists() {
958 std::fs::rename(&config_path, backup_config_path)?;
959 }
957 if config_path.exists() { 960 if config_path.exists() {
958 std::fs::rename(config_path, backup_config_path)?; 961 std::fs::remove_file(&config_path)?;
959 } 962 }
960 Ok(()) 963 Ok(())
961} 964}
962 965
963fn restore_config_backup() -> Result<()> { 966fn restore_config_backup() -> Result<()> {
964 let config_path = get_dirs().config_dir().join("config.json"); 967 let config_path = get_dirs().config_dir().join("cache.sqlite");
965 let backup_config_path = get_dirs().config_dir().join("config-backup.json"); 968 let backup_config_path = get_dirs().config_dir().join("cache-backup.sqlite");
966 if config_path.exists() { 969 if config_path.exists() {
967 std::fs::remove_file(&config_path)?; 970 std::fs::remove_file(&config_path)?;
968 } 971 }
diff --git a/tests/init.rs b/tests/init.rs
index 40b5a45..4e4b04f 100644
--- a/tests/init.rs
+++ b/tests/init.rs
@@ -4,7 +4,6 @@ use test_utils::{git::GitTestRepo, *};
4 4
5fn expect_msgs_first(p: &mut CliTester) -> Result<()> { 5fn expect_msgs_first(p: &mut CliTester) -> Result<()> {
6 p.expect("searching for profile and relay updates...\r\n")?; 6 p.expect("searching for profile and relay updates...\r\n")?;
7 p.expect("\r")?;
8 p.expect("logged in as fred\r\n")?; 7 p.expect("logged in as fred\r\n")?;
9 // // p.expect("searching for existing claims on repository...\r\n")?; 8 // // p.expect("searching for existing claims on repository...\r\n")?;
10 p.expect("publishing repostory reference...\r\n")?; 9 p.expect("publishing repostory reference...\r\n")?;
diff --git a/tests/login.rs b/tests/login.rs
index 371a7e7..aaadf00 100644
--- a/tests/login.rs
+++ b/tests/login.rs
@@ -2,17 +2,24 @@ use anyhow::Result;
2use serial_test::serial; 2use serial_test::serial;
3use test_utils::*; 3use test_utils::*;
4 4
5static EXPECTED_NSEC_PROMPT: &str = "login with nsec (or hex private key)"; 5static EXPECTED_NSEC_PROMPT: &str = "login with nsec";
6static EXPECTED_LOCAL_REPOSITORY_PROMPT: &str = "just for this repository?";
7static EXPECTED_REQUIRE_PASSWORD_PROMPT: &str = "require password?";
6static EXPECTED_SET_PASSWORD_PROMPT: &str = "encrypt with password"; 8static EXPECTED_SET_PASSWORD_PROMPT: &str = "encrypt with password";
7static EXPECTED_SET_PASSWORD_CONFIRM_PROMPT: &str = "confirm password"; 9static EXPECTED_SET_PASSWORD_CONFIRM_PROMPT: &str = "confirm password";
8static EXPECTED_PASSWORD_PROMPT: &str = "password";
9 10
10fn standard_login() -> Result<CliTester> { 11fn standard_first_time_login_encrypting_nsec() -> Result<CliTester> {
11 let mut p = CliTester::new(["login", "--offline"]); 12 let mut p = CliTester::new(["login", "--offline"]);
12 13
13 p.expect_input_eventually(EXPECTED_NSEC_PROMPT)? 14 p.expect_input_eventually(EXPECTED_NSEC_PROMPT)?
14 .succeeds_with(TEST_KEY_1_NSEC)?; 15 .succeeds_with(TEST_KEY_1_NSEC)?;
15 16
17 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
18 .succeeds_with(Some(true))?;
19
20 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
21 .succeeds_with(Some(true))?;
22
16 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 23 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)?
17 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 24 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)?
18 .succeeds_with(TEST_PASSWORD)?; 25 .succeeds_with(TEST_PASSWORD)?;
@@ -56,16 +63,14 @@ mod with_relays {
56 p.expect_input(EXPECTED_NSEC_PROMPT)? 63 p.expect_input(EXPECTED_NSEC_PROMPT)?
57 .succeeds_with(TEST_KEY_1_NSEC)?; 64 .succeeds_with(TEST_KEY_1_NSEC)?;
58 65
59 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 66 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
60 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 67 .succeeds_with(Some(true))?;
61 .succeeds_with(TEST_PASSWORD)?; 68
69 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
70 .succeeds_with(Some(false))?;
62 71
63 p.expect("searching for profile and relay updates...\r\n")?; 72 p.expect("searching for profile and relay updates...\r\n")?;
64 p.expect("\r")?;
65 73
66 // p.expect_end_with(
67 // format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str(),
68 // )?;
69 p.expect_end_with("logged in as fred\r\n")?; 74 p.expect_end_with("logged in as fred\r\n")?;
70 for p in [51, 52] { 75 for p in [51, 52] {
71 shutdown_relay(8000 + p)?; 76 shutdown_relay(8000 + p)?;
@@ -97,12 +102,15 @@ mod with_relays {
97 p.expect_input(EXPECTED_NSEC_PROMPT)? 102 p.expect_input(EXPECTED_NSEC_PROMPT)?
98 .succeeds_with(TEST_KEY_1_NSEC)?; 103 .succeeds_with(TEST_KEY_1_NSEC)?;
99 104
100 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 105 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
101 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 106 .succeeds_with(Some(true))?;
102 .succeeds_with(TEST_PASSWORD)?; 107
108 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
109 .succeeds_with(Some(false))?;
103 110
104 p.expect("searching for profile and relay updates...\r\n")?; 111 p.expect("searching for profile and relay updates...\r\n")?;
105 p.expect("\r")?; 112
113 p.expect("cannot extract account name from account metadata...\r\n")?;
106 114
107 p.expect_end_with( 115 p.expect_end_with(
108 format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str(), 116 format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str(),
@@ -220,19 +228,6 @@ mod with_relays {
220 #[serial] 228 #[serial]
221 async fn displays_npub_when_metadata_contains_no_name_displayname_or_display_name() 229 async fn displays_npub_when_metadata_contains_no_name_displayname_or_display_name()
222 -> Result<()> { 230 -> Result<()> {
223 println!(
224 "displayName: {}",
225 nostr::Metadata::new()
226 .custom_field("displayName", "fred")
227 .custom
228 .get("displayName")
229 .unwrap()
230 );
231 println!(
232 "name: {}",
233 nostr::Metadata::new().name("fred").name.unwrap()
234 );
235
236 run_test_displays_fallback_to_npub( 231 run_test_displays_fallback_to_npub(
237 Some(&|relay, client_id, subscription_id, _| -> Result<()> { 232 Some(&|relay, client_id, subscription_id, _| -> Result<()> {
238 relay.respond_events( 233 relay.respond_events(
@@ -240,7 +235,7 @@ mod with_relays {
240 &subscription_id, 235 &subscription_id,
241 &vec![ 236 &vec![
242 nostr::event::EventBuilder::metadata( 237 nostr::event::EventBuilder::metadata(
243 &nostr::Metadata::new(), 238 &nostr::Metadata::new().about("other info in metadata"),
244 ) 239 )
245 .to_event(&TEST_KEY_1_KEYS) 240 .to_event(&TEST_KEY_1_KEYS)
246 .unwrap(), 241 .unwrap(),
@@ -419,7 +414,6 @@ mod with_relays {
419 let mut p = CliTester::new(["login", "--nsec", TEST_KEY_1_NSEC]); 414 let mut p = CliTester::new(["login", "--nsec", TEST_KEY_1_NSEC]);
420 415
421 p.expect("searching for profile and relay updates...\r\n")?; 416 p.expect("searching for profile and relay updates...\r\n")?;
422 p.expect("\r")?;
423 417
424 p.expect_end_with("logged in as fred\r\n")?; 418 p.expect_end_with("logged in as fred\r\n")?;
425 for p in [51, 52] { 419 for p in [51, 52] {
@@ -482,7 +476,6 @@ mod with_relays {
482 let mut p = CliTester::new(["login", "--password", TEST_PASSWORD]); 476 let mut p = CliTester::new(["login", "--password", TEST_PASSWORD]);
483 477
484 p.expect("searching for profile and relay updates...\r\n")?; 478 p.expect("searching for profile and relay updates...\r\n")?;
485 p.expect("\r")?;
486 479
487 p.expect_end_with("logged in as fred\r\n")?; 480 p.expect_end_with("logged in as fred\r\n")?;
488 for p in [51, 52] { 481 for p in [51, 52] {
@@ -541,7 +534,6 @@ mod with_relays {
541 ]); 534 ]);
542 535
543 p.expect("searching for profile and relay updates...\r\n")?; 536 p.expect("searching for profile and relay updates...\r\n")?;
544 p.expect("\r")?;
545 537
546 p.expect_end_with("logged in as fred\r\n")?; 538 p.expect_end_with("logged in as fred\r\n")?;
547 for p in [51, 52] { 539 for p in [51, 52] {
@@ -585,12 +577,14 @@ mod with_relays {
585 p.expect_input(EXPECTED_NSEC_PROMPT)? 577 p.expect_input(EXPECTED_NSEC_PROMPT)?
586 .succeeds_with(TEST_KEY_1_NSEC)?; 578 .succeeds_with(TEST_KEY_1_NSEC)?;
587 579
588 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 580 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
589 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 581 .succeeds_with(Some(true))?;
590 .succeeds_with(TEST_PASSWORD)?; 582
583 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
584 .succeeds_with(Some(false))?;
591 585
592 p.expect("searching for profile and relay updates...\r\n")?; 586 p.expect("searching for profile and relay updates...\r\n")?;
593 p.expect("\r")?; 587
594 p.expect( 588 p.expect(
595 "cannot find your account metadata (name, etc) on relays\r\n", 589 "cannot find your account metadata (name, etc) on relays\r\n",
596 )?; 590 )?;
@@ -649,12 +643,14 @@ mod with_relays {
649 p.expect_input(EXPECTED_NSEC_PROMPT)? 643 p.expect_input(EXPECTED_NSEC_PROMPT)?
650 .succeeds_with(TEST_KEY_1_NSEC)?; 644 .succeeds_with(TEST_KEY_1_NSEC)?;
651 645
652 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 646 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
653 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 647 .succeeds_with(Some(true))?;
654 .succeeds_with(TEST_PASSWORD)?; 648
649 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
650 .succeeds_with(Some(false))?;
655 651
656 p.expect("searching for profile and relay updates...\r\n")?; 652 p.expect("searching for profile and relay updates...\r\n")?;
657 p.expect("\r")?; 653
658 p.expect("cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience.\r\n")?; 654 p.expect("cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience.\r\n")?;
659 655
660 p.expect_end_with("logged in as fred\r\n")?; 656 p.expect_end_with("logged in as fred\r\n")?;
@@ -677,11 +673,6 @@ mod with_relays {
677 mod when_second_time_login_and_details_already_fetched { 673 mod when_second_time_login_and_details_already_fetched {
678 use super::*; 674 use super::*;
679 675
680 // TODO: the following two tests would require a fake config file or
681 // fake time
682 // - uses_relays_from_user_relay_list
683 // - dislays_correct_name - when_local_metadata_is_the_most_recent
684
685 mod uses_cache { 676 mod uses_cache {
686 use super::*; 677 use super::*;
687 678
@@ -730,7 +721,6 @@ mod with_relays {
730 let mut p = CliTester::new(["login", "--password", TEST_PASSWORD]); 721 let mut p = CliTester::new(["login", "--password", TEST_PASSWORD]);
731 722
732 p.expect("searching for profile and relay updates...\r\n")?; 723 p.expect("searching for profile and relay updates...\r\n")?;
733 p.expect("\r")?;
734 724
735 p.expect_end_eventually_with("logged in as fred\r\n")?; 725 p.expect_end_eventually_with("logged in as fred\r\n")?;
736 726
@@ -770,12 +760,13 @@ mod with_relays {
770 p.expect_input(EXPECTED_NSEC_PROMPT)? 760 p.expect_input(EXPECTED_NSEC_PROMPT)?
771 .succeeds_with(TEST_KEY_1_NSEC)?; 761 .succeeds_with(TEST_KEY_1_NSEC)?;
772 762
773 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 763 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
774 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 764 .succeeds_with(Some(true))?;
775 .succeeds_with(TEST_PASSWORD)?; 765
766 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
767 .succeeds_with(Some(false))?;
776 768
777 p.expect("searching for profile and relay updates...\r\n")?; 769 p.expect("searching for profile and relay updates...\r\n")?;
778 p.expect("\r")?;
779 770
780 p.expect_end_with("logged in as fred\r\n")?; 771 p.expect_end_with("logged in as fred\r\n")?;
781 for p in [51, 52, 53, 55] { 772 for p in [51, 52, 53, 55] {
@@ -842,7 +833,7 @@ mod with_offline_flag {
842 #[serial] 833 #[serial]
843 fn prompts_for_nsec_and_password() -> Result<()> { 834 fn prompts_for_nsec_and_password() -> Result<()> {
844 before()?; 835 before()?;
845 standard_login()?; 836 standard_first_time_login_encrypting_nsec()?;
846 after() 837 after()
847 } 838 }
848 839
@@ -855,6 +846,12 @@ mod with_offline_flag {
855 p.expect_input(EXPECTED_NSEC_PROMPT)? 846 p.expect_input(EXPECTED_NSEC_PROMPT)?
856 .succeeds_with(TEST_KEY_1_NSEC)?; 847 .succeeds_with(TEST_KEY_1_NSEC)?;
857 848
849 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
850 .succeeds_with(Some(true))?;
851
852 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
853 .succeeds_with(Some(true))?;
854
858 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 855 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)?
859 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 856 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)?
860 .succeeds_with(TEST_PASSWORD)?; 857 .succeeds_with(TEST_PASSWORD)?;
@@ -872,6 +869,12 @@ mod with_offline_flag {
872 p.expect_input(EXPECTED_NSEC_PROMPT)? 869 p.expect_input(EXPECTED_NSEC_PROMPT)?
873 .succeeds_with(TEST_KEY_1_SK_HEX)?; 870 .succeeds_with(TEST_KEY_1_SK_HEX)?;
874 871
872 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
873 .succeeds_with(Some(true))?;
874
875 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
876 .succeeds_with(Some(true))?;
877
875 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 878 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)?
876 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 879 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)?
877 .succeeds_with(TEST_PASSWORD)?; 880 .succeeds_with(TEST_PASSWORD)?;
@@ -904,6 +907,12 @@ mod with_offline_flag {
904 p.expect_input(invalid_nsec_response)? 907 p.expect_input(invalid_nsec_response)?
905 .succeeds_with(TEST_KEY_1_NSEC)?; 908 .succeeds_with(TEST_KEY_1_NSEC)?;
906 909
910 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
911 .succeeds_with(Some(true))?;
912
913 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
914 .succeeds_with(Some(true))?;
915
907 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 916 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)?
908 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 917 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)?
909 .succeeds_with(TEST_PASSWORD)?; 918 .succeeds_with(TEST_PASSWORD)?;
@@ -914,53 +923,6 @@ mod with_offline_flag {
914 } 923 }
915 } 924 }
916 925
917 mod when_second_time_login {
918 use super::*;
919
920 #[test]
921 #[serial]
922 fn prints_login_as_npub() -> Result<()> {
923 with_fresh_config(|| {
924 standard_login()?.exit()?;
925
926 CliTester::new(["login", "--offline"])
927 .expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())?
928 .exit()
929 })
930 }
931
932 #[test]
933 #[serial]
934 fn prompts_for_password_and_succeeds_with_logged_in_as_npub() -> Result<()> {
935 with_fresh_config(|| {
936 standard_login()?.exit()?;
937
938 let mut p = CliTester::new(["login", "--offline"]);
939
940 p.expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())?
941 .expect_password(EXPECTED_PASSWORD_PROMPT)?
942 .succeeds_with(TEST_PASSWORD)?;
943
944 p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())
945 })
946 }
947
948 #[test]
949 #[serial]
950 fn when_invalid_password_exit_with_error() -> Result<()> {
951 with_fresh_config(|| {
952 standard_login()?.exit()?;
953
954 let mut p = CliTester::new(["login", "--offline"]);
955
956 p.expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())?
957 .expect_password(EXPECTED_PASSWORD_PROMPT)?
958 .succeeds_with(TEST_INVALID_PASSWORD)?;
959 p.expect_end_with(format!("Error: failed to log in as {}\r\n\r\nCaused by:\r\n 0: failed to decrypt key with provided password\r\n 1: ChaCha20Poly1305: aead::Error\r\n", TEST_KEY_1_NPUB).as_str())
960 })
961 }
962 }
963
964 mod when_called_with_nsec_parameter_only { 926 mod when_called_with_nsec_parameter_only {
965 use super::*; 927 use super::*;
966 928
@@ -996,7 +958,7 @@ mod with_offline_flag {
996 #[serial] 958 #[serial]
997 fn valid_nsec_param_succeeds_without_prompts_and_logs_in() -> Result<()> { 959 fn valid_nsec_param_succeeds_without_prompts_and_logs_in() -> Result<()> {
998 with_fresh_config(|| { 960 with_fresh_config(|| {
999 standard_login()?.exit()?; 961 standard_first_time_login_encrypting_nsec()?.exit()?;
1000 962
1001 CliTester::new(["login", "--offline", "--nsec", TEST_KEY_2_NSEC]) 963 CliTester::new(["login", "--offline", "--nsec", TEST_KEY_2_NSEC])
1002 .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str()) 964 .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str())
@@ -1035,26 +997,6 @@ mod with_offline_flag {
1035 997
1036 #[test] 998 #[test]
1037 #[serial] 999 #[serial]
1038 fn remembers_identity() -> Result<()> {
1039 with_fresh_config(|| {
1040 CliTester::new([
1041 "login",
1042 "--offline",
1043 "--nsec",
1044 TEST_KEY_1_NSEC,
1045 "--password",
1046 TEST_PASSWORD,
1047 ])
1048 .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?;
1049
1050 CliTester::new(["login", "--offline"])
1051 .expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())?
1052 .exit()
1053 })
1054 }
1055
1056 #[test]
1057 #[serial]
1058 fn parameters_can_be_called_globally() -> Result<()> { 1000 fn parameters_can_be_called_globally() -> Result<()> {
1059 with_fresh_config(|| { 1001 with_fresh_config(|| {
1060 CliTester::new([ 1002 CliTester::new([
@@ -1076,7 +1018,7 @@ mod with_offline_flag {
1076 #[serial] 1018 #[serial]
1077 fn valid_nsec_param_succeeds_without_prompts_and_logs_in() -> Result<()> { 1019 fn valid_nsec_param_succeeds_without_prompts_and_logs_in() -> Result<()> {
1078 with_fresh_config(|| { 1020 with_fresh_config(|| {
1079 standard_login()?.exit()?; 1021 standard_first_time_login_encrypting_nsec()?.exit()?;
1080 1022
1081 CliTester::new([ 1023 CliTester::new([
1082 "login", 1024 "login",
@@ -1089,28 +1031,6 @@ mod with_offline_flag {
1089 .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str()) 1031 .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str())
1090 }) 1032 })
1091 } 1033 }
1092
1093 #[test]
1094 #[serial]
1095 fn remembers_identity() -> Result<()> {
1096 with_fresh_config(|| {
1097 standard_login()?.exit()?;
1098
1099 CliTester::new([
1100 "login",
1101 "--offline",
1102 "--nsec",
1103 TEST_KEY_2_NSEC,
1104 "--password",
1105 TEST_PASSWORD,
1106 ])
1107 .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_2_NPUB).as_str())?;
1108
1109 CliTester::new(["login", "--offline"])
1110 .expect(format!("login as {}\r\n", TEST_KEY_2_NPUB).as_str())?
1111 .exit()
1112 })
1113 }
1114 } 1034 }
1115 1035
1116 mod when_provided_with_new_password { 1036 mod when_provided_with_new_password {
@@ -1120,7 +1040,7 @@ mod with_offline_flag {
1120 #[serial] 1040 #[serial]
1121 fn password_changes() -> Result<()> { 1041 fn password_changes() -> Result<()> {
1122 with_fresh_config(|| { 1042 with_fresh_config(|| {
1123 standard_login()?.exit()?; 1043 standard_first_time_login_encrypting_nsec()?.exit()?;
1124 1044
1125 CliTester::new([ 1045 CliTester::new([
1126 "login", 1046 "login",
@@ -1157,31 +1077,6 @@ mod with_offline_flag {
1157 } 1077 }
1158 } 1078 }
1159 1079
1160 mod when_called_with_password_parameter_only {
1161 use super::*;
1162
1163 #[test]
1164 #[serial]
1165 fn when_nsec_stored_logs_in_without_prompts() -> Result<()> {
1166 with_fresh_config(|| {
1167 standard_login()?.exit()?;
1168
1169 CliTester::new(["login", "--offline", "--password", TEST_PASSWORD])
1170 .expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())
1171 })
1172 }
1173
1174 #[test]
1175 #[serial]
1176 fn when_no_nsec_stored_logs_error() -> Result<()> {
1177 with_fresh_config(|| {
1178 CliTester::new(["login", "--offline", "--password", TEST_PASSWORD]).expect_end_with(
1179 "Error: no nsec available to decrypt with specified password\r\n",
1180 )
1181 })
1182 }
1183 }
1184
1185 mod when_weak_password { 1080 mod when_weak_password {
1186 use super::*; 1081 use super::*;
1187 1082
@@ -1195,23 +1090,34 @@ mod with_offline_flag {
1195 p.expect_input(EXPECTED_NSEC_PROMPT)? 1090 p.expect_input(EXPECTED_NSEC_PROMPT)?
1196 .succeeds_with(TEST_KEY_1_NSEC)?; 1091 .succeeds_with(TEST_KEY_1_NSEC)?;
1197 1092
1093 p.expect_confirm(EXPECTED_LOCAL_REPOSITORY_PROMPT, Some(false))?
1094 .succeeds_with(Some(true))?;
1095
1096 p.expect_confirm(EXPECTED_REQUIRE_PASSWORD_PROMPT, Some(false))?
1097 .succeeds_with(Some(true))?;
1098
1198 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)? 1099 p.expect_password(EXPECTED_SET_PASSWORD_PROMPT)?
1199 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)? 1100 .with_confirmation(EXPECTED_SET_PASSWORD_CONFIRM_PROMPT)?
1200 .succeeds_with(TEST_WEAK_PASSWORD)?; 1101 .succeeds_with(TEST_WEAK_PASSWORD)?;
1201 1102
1202 p.expect("this may take a few seconds...\r\n")?; 1103 p.expect("this may take a few seconds...\r\n")?;
1203 1104
1204 p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())?; 1105 p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str())
1205 1106
1206 p = CliTester::new_with_timeout(10000, ["login", "--offline"]); 1107 // commented out as 'login' command now assumes you want to
1108 // login as a new user
1109 // p = CliTester::new_with_timeout(10000, ["login",
1110 // "--offline"]);
1207 1111
1208 p.expect(format!("login as {}\r\n", TEST_KEY_1_NPUB).as_str())? 1112 // p.expect(format!("login as {}\r\n",
1209 .expect_password(EXPECTED_PASSWORD_PROMPT)? 1113 // TEST_KEY_1_NPUB).as_str())?
1210 .succeeds_with(TEST_WEAK_PASSWORD)?; 1114 // .expect_password(EXPECTED_PASSWORD_PROMPT)?
1115 // .succeeds_with(TEST_WEAK_PASSWORD)?;
1211 1116
1212 p.expect("this may take a few seconds...\r\n")?; 1117 // p.expect("this may take a few seconds...\r\n")?;
1213 1118
1214 p.expect_end_with(format!("logged in as {}\r\n", TEST_KEY_1_NPUB).as_str()) 1119 // p.expect_end_with(format!("logged in as {}\r\n",
1120 // TEST_KEY_1_NPUB).as_str())
1215 }) 1121 })
1216 } 1122 }
1217 } 1123 }
diff --git a/tests/push.rs b/tests/push.rs
index 5fe1f15..fe711c1 100644
--- a/tests/push.rs
+++ b/tests/push.rs
@@ -347,7 +347,6 @@ mod when_branch_is_checked_out {
347 "1 commits ahead. preparing to create creating patch events.\r\n", 347 "1 commits ahead. preparing to create creating patch events.\r\n",
348 )?; 348 )?;
349 p.expect("searching for profile and relay updates...\r\n")?; 349 p.expect("searching for profile and relay updates...\r\n")?;
350 p.expect("\r")?;
351 p.expect("logged in as fred\r\n")?; 350 p.expect("logged in as fred\r\n")?;
352 p.expect("pushing 1 commits\r\n")?; 351 p.expect("pushing 1 commits\r\n")?;
353 352
@@ -593,7 +592,6 @@ mod when_branch_is_checked_out {
593 p.expect("355bdf1 add a4.md\r\n")?; 592 p.expect("355bdf1 add a4.md\r\n")?;
594 p.expect("dbd1115 add a3.md\r\n")?; 593 p.expect("dbd1115 add a3.md\r\n")?;
595 p.expect("searching for profile and relay updates...\r\n")?; 594 p.expect("searching for profile and relay updates...\r\n")?;
596 p.expect("\r")?;
597 p.expect("logged in as fred\r\n")?; 595 p.expect("logged in as fred\r\n")?;
598 p.expect("posting 2 patches without a covering letter...\r\n")?; 596 p.expect("posting 2 patches without a covering letter...\r\n")?;
599 597
diff --git a/tests/send.rs b/tests/send.rs
index 22216a8..2c95e1e 100644
--- a/tests/send.rs
+++ b/tests/send.rs
@@ -134,7 +134,6 @@ fn expect_msgs_first(p: &mut CliTester, include_cover_letter: bool) -> Result<()
134 p.expect("fe973a8 add t4.md\r\n")?; 134 p.expect("fe973a8 add t4.md\r\n")?;
135 p.expect("232efb3 add t3.md\r\n")?; 135 p.expect("232efb3 add t3.md\r\n")?;
136 p.expect("searching for profile and relay updates...\r\n")?; 136 p.expect("searching for profile and relay updates...\r\n")?;
137 p.expect("\r")?;
138 p.expect("logged in as fred\r\n")?; 137 p.expect("logged in as fred\r\n")?;
139 p.expect(format!( 138 p.expect(format!(
140 "posting 2 patches {} a covering letter...\r\n", 139 "posting 2 patches {} a covering letter...\r\n",
@@ -1163,7 +1162,6 @@ mod when_range_ommited_prompts_for_selection_defaulting_ahead_of_main {
1163 p.expect("fe973a8 add t4.md\r\n")?; 1162 p.expect("fe973a8 add t4.md\r\n")?;
1164 p.expect("232efb3 add t3.md\r\n")?; 1163 p.expect("232efb3 add t3.md\r\n")?;
1165 p.expect("searching for profile and relay updates...\r\n")?; 1164 p.expect("searching for profile and relay updates...\r\n")?;
1166 p.expect("\r")?;
1167 p.expect("logged in as fred\r\n")?; 1165 p.expect("logged in as fred\r\n")?;
1168 p.expect("posting 2 patches without a covering letter...\r\n")?; 1166 p.expect("posting 2 patches without a covering letter...\r\n")?;
1169 Ok(()) 1167 Ok(())
@@ -1358,7 +1356,6 @@ mod root_proposal_specified_using_in_reply_to_with_range_of_head_2_and_cover_let
1358 p.expect("fe973a8 add t4.md\r\n")?; 1356 p.expect("fe973a8 add t4.md\r\n")?;
1359 p.expect("232efb3 add t3.md\r\n")?; 1357 p.expect("232efb3 add t3.md\r\n")?;
1360 p.expect("searching for profile and relay updates...\r\n")?; 1358 p.expect("searching for profile and relay updates...\r\n")?;
1361 p.expect("\r")?;
1362 p.expect("logged in as fred\r\n")?; 1359 p.expect("logged in as fred\r\n")?;
1363 p.expect(format!( 1360 p.expect(format!(
1364 "posting 2 patches {} a covering letter...\r\n", 1361 "posting 2 patches {} a covering letter...\r\n",