steid

@jamesgill /

steid/src/cli/reset_password.rs
Rust4.8 KBCode·Blame·History·Raw
1//! `steid reset-password <handle>`.
2
3use crate::{
4 application::{AppConfig, IssuedReset, mint_reset_for_handle},
5 infrastructure::repository::{SqliteOrgRepo, SqlitePasswordResetRepo, SqliteUserRepo},
6};
7
8/// Prints a one-time reset link for a handle, and exits 0 either way.
9///
10/// **Prints nothing for a handle nobody holds, and still exits 0.** That is
11/// distinguishable, and it is meant to be: the no-enumeration rule protects a stranger
12/// on the network from learning which accounts exist, and whoever can run this command
13/// has a shell on the box and the SQLite file in front of them. Refusing to say would
14/// protect nothing and would make the one tool a locked-out owner has impossible to
15/// tell apart from a typo.
16///
17/// The link needs an address, and a command line has no request to derive one from — so
18/// `STEID_PUBLIC_URL` when it is set, and the bare path with an instruction when it is
19/// not. `install.sh` writes it from `--domain`.
20///
21/// # Errors
22///
23/// Propagates a storage failure. A handle nobody holds is not one.
24pub async fn print_reset_link(
25 handle: &str,
26 config: &AppConfig,
27 pool: &sqlx::SqlitePool,
28) -> Result<(), Box<dyn std::error::Error>> {
29 if let Some(message) = reset_link_message(handle, config, pool).await? {
30 println!("{message}");
31 }
32
33 Ok(())
34}
35
36/// What [`print_reset_link`] prints, or `None` for a handle nobody holds.
37///
38/// Split out so the shape of the message — and the silence — is testable without
39/// capturing the process's standard output.
40async fn reset_link_message(
41 handle: &str,
42 config: &AppConfig,
43 pool: &sqlx::SqlitePool,
44) -> Result<Option<String>, Box<dyn std::error::Error>> {
45 let issued = mint_reset_for_handle(
46 handle,
47 std::time::SystemTime::now(),
48 &SqliteUserRepo::new(pool.clone()),
49 &SqliteOrgRepo::new(pool.clone()),
50 &SqlitePasswordResetRepo::new(pool.clone()),
51 )
52 .await?;
53
54 Ok(issued.map(|issued| message(&issued, config.public_url.as_deref())))
55}
56
57/// The link, the caveat about the address, and what the reader has to know about it.
58fn message(issued: &IssuedReset, public_url: Option<&str>) -> String {
59 let mut text = match public_url {
60 Some(origin) => issued.link(origin),
61 None => format!(
62 "{}\n\n STEID_PUBLIC_URL is unset, so that is a path rather than a link.\n \
63 Put this instance's address in front of it.",
64 issued.link("")
65 ),
66 };
67
68 text.push_str(
69 "\n\n It works once and expires in 30 minutes. Anyone who reads it can take\n \
70 the account, so hand it over the way you would a password.",
71 );
72
73 text
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79 use crate::cli::fixture;
80
81 #[tokio::test]
82 async fn a_known_handle_gets_a_link_under_the_configured_address() {
83 let mut instance = fixture::claimed("ada").await;
84 instance.config.public_url = Some("https://git.example.com".to_owned());
85
86 let message = reset_link_message("ada", &instance.config, &instance.pool)
87 .await
88 .expect("the query runs")
89 .expect("ada holds a handle");
90
91 assert!(
92 message.starts_with("https://git.example.com/auth/reset/"),
93 "{message} is not a link"
94 );
95 assert!(message.contains("expires in 30 minutes"));
96 }
97
98 #[tokio::test]
99 async fn without_a_configured_address_it_prints_a_path_and_says_so() {
100 let instance = fixture::claimed("ada").await;
101
102 let message = reset_link_message("ada", &instance.config, &instance.pool)
103 .await
104 .expect("the query runs")
105 .expect("ada holds a handle");
106
107 assert!(
108 message.starts_with("/auth/reset/"),
109 "{message} is not a path"
110 );
111 assert!(message.contains("STEID_PUBLIC_URL is unset"));
112 }
113
114 #[tokio::test]
115 async fn a_handle_nobody_holds_prints_nothing_and_is_not_an_error() {
116 // Deliberately distinguishable from a hit: whoever runs this has the SQLite file
117 // in front of them, and the alternative makes a typo indistinguishable from a
118 // locked-out owner. See the doc comment.
119 let instance = fixture::claimed("ada").await;
120
121 let message = reset_link_message("grace", &instance.config, &instance.pool)
122 .await
123 .expect("the query runs");
124
125 assert!(message.is_none());
126 }
127
128 #[tokio::test]
129 async fn issuing_a_second_link_is_not_an_error() {
130 // The use case spends the first one; the command must not surface that as a
131 // failure to an owner who ran it twice.
132 let instance = fixture::claimed("ada").await;
133
134 for _ in 0..2 {
135 print_reset_link("ada", &instance.config, &instance.pool)
136 .await
137 .expect("the command succeeds");
138 }
139 }
140}