Skip to main content

max / goingson

12.0 KB · 371 lines History Blame Raw
1 //! The email accounts section, driven through the router against a real database.
2
3 use std::sync::Arc;
4
5 use goingson_core::{EmailAccount, EmailAccountId, NewEmailAccount};
6 use quasi_http::Serves as _;
7 use quasi_router::Outcome;
8 use quasi_router::{Params, Request, Response};
9
10 use crate::quasi::router;
11 use crate::state::{AppState, DESKTOP_USER_ID};
12
13 async fn state() -> Arc<AppState> {
14 let (state, _) = crate::test_utils::setup_test_state().await;
15 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
16 state
17 .db
18 .conn()
19 .unwrap()
20 .execute(
21 "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \
22 VALUES (?, ?, ?, ?, ?)",
23 rusqlite::params![
24 DESKTOP_USER_ID.to_string(),
25 "desktop@localhost",
26 "x",
27 "Desktop User",
28 &now,
29 ],
30 )
31 .unwrap();
32 state
33 }
34
35 fn account(state: &AppState, name: &str) -> EmailAccount {
36 state
37 .email_accounts
38 .create(
39 DESKTOP_USER_ID,
40 NewEmailAccount {
41 account_name: name,
42 email_address: "someone@example.com",
43 imap_server: "imap.example.com",
44 imap_port: 993,
45 smtp_server: "smtp.example.com",
46 smtp_port: 587,
47 username: "someone@example.com",
48 password: "",
49 use_tls: true,
50 archive_folder_name: Some("Archive"),
51 },
52 )
53 .unwrap()
54 }
55
56 fn html(response: Response) -> String {
57 match response.outcome {
58 Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen),
59 Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node),
60 Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"),
61 Outcome::Over(_) => panic!("expected content, got a screen drawn over it"),
62 Outcome::Anchored { .. } => {
63 panic!("expected content, got a screen drawn at a point on it")
64 }
65 Outcome::Suggestions { field, .. } => {
66 panic!("expected content, got a suggestion list for `{field}`")
67 }
68 Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"),
69 Outcome::Locate(_) => panic!("expected content, got a place on a map"),
70 // `cb62a9dc`. Work that runs somewhere else and a region that says so:
71 // not content, and not a place either.
72 Outcome::Started { region, .. } => {
73 panic!("expected content, got work started in `{region}`")
74 }
75 }
76 }
77
78 fn get(state: &AppState, path: &str, params: Params) -> Response {
79 router()
80 .handle(state, Request::get(path).carrying(params))
81 .expect("the route answers")
82 }
83
84 fn post(state: &AppState, path: &str, params: Params) -> Response {
85 router()
86 .handle(state, Request::post(path).sending(params))
87 .expect("the route answers")
88 }
89
90 fn section(state: &AppState) -> String {
91 html(get(state, "/settings/email", Params::new()))
92 }
93
94 /// A complete, valid submission. Tests override the one field they are about.
95 ///
96 /// The override replaces the default rather than being appended after it.
97 /// `Params::with` appends, and `get` answers with the first value, so building
98 /// this by chaining defaults and then overrides would silently keep every
99 /// default -- which is exactly what it did until 2026-08-21 and made four
100 /// refusal tests pass a submission they were meant to refuse.
101 fn submission(overrides: &[(&str, &str)]) -> Params {
102 const DEFAULTS: [(&str, &str); 9] = [
103 ("account_name", "Personal"),
104 ("email_address", "someone@example.com"),
105 ("username", "someone@example.com"),
106 ("password", "hunter2"),
107 ("archive_folder_name", "Archive"),
108 ("imap_server", "imap.example.com"),
109 ("imap_port", "993"),
110 ("smtp_server", "smtp.example.com"),
111 ("smtp_port", "587"),
112 ];
113
114 let mut params = Params::new();
115 for (name, default) in DEFAULTS {
116 let value = overrides
117 .iter()
118 .find(|(over, _)| *over == name)
119 .map_or(default, |(_, value)| *value);
120 params = params.with(name, value);
121 }
122 // Anything the defaults do not name, such as `advanced`.
123 for (name, value) in overrides {
124 if !DEFAULTS.iter().any(|(known, _)| known == name) {
125 params = params.with(*name, *value);
126 }
127 }
128 params
129 }
130
131 #[tokio::test]
132 async fn email_is_a_section_of_settings() {
133 // The claim being corrected: settings.rs's header lists Email among the
134 // sections that are "about the host rather than about the app".
135 let state = state().await;
136 let page = html(get(&state, "/settings", Params::new()));
137 assert!(page.contains("Email"), "{page}");
138 assert!(page.contains("/settings/email"), "{page}");
139 }
140
141 #[tokio::test]
142 async fn an_empty_section_says_so_and_offers_the_form() {
143 let state = state().await;
144 let page = section(&state);
145 assert!(page.contains("No accounts yet."), "{page}");
146 assert!(page.contains("/settings/email/new"), "{page}");
147 }
148
149 #[tokio::test]
150 async fn email_is_a_literal_rather_than_a_section_named_email() {
151 // Mounted above `/settings/{section}`. Read the other way it would be a
152 // section slug, and `section_of` has no such entry, so this would 404.
153 let state = state().await;
154 let form = html(get(&state, "/settings/email/new", Params::new()));
155 assert!(form.contains("Add account"), "{form}");
156 }
157
158 #[tokio::test]
159 async fn the_form_asks_what_the_modal_asks() {
160 let state = state().await;
161 let form = html(get(&state, "/settings/email/new", Params::new()));
162 for name in [
163 "account_name",
164 "email_address",
165 "username",
166 "password",
167 "archive_folder_name",
168 "email_signature",
169 "imap_server",
170 "imap_port",
171 "smtp_server",
172 "smtp_port",
173 ] {
174 assert!(form.contains(&format!("name=\"{name}\"")), "{name}: {form}");
175 }
176 }
177
178 #[tokio::test]
179 async fn the_password_is_a_secret_rather_than_text() {
180 // `FieldKind::Secret` is the kind whose contract is that the value is never
181 // echoed or round-tripped. A password in a `Text` field would render as one
182 // anybody looking at the screen can read.
183 let state = state().await;
184 let form = html(get(&state, "/settings/email/new", Params::new()));
185 assert!(form.contains("type=\"password\""), "{form}");
186 }
187
188 #[tokio::test]
189 async fn creating_an_account_puts_it_in_the_section() {
190 let state = state().await;
191
192 let page = html(post(&state, "/settings/email", submission(&[])));
193 assert!(page.contains("Personal"), "{page}");
194
195 let stored = state.email_accounts.list_by_user(DESKTOP_USER_ID).unwrap();
196 assert_eq!(stored.len(), 1);
197 assert_eq!(stored[0].imap_server, "imap.example.com");
198 assert_eq!(stored[0].smtp_port, 587);
199 }
200
201 #[tokio::test]
202 async fn the_password_never_reaches_the_row() {
203 // `NewEmailAccount`'s own instruction: the column is written empty and the
204 // secret goes to the OS keychain. A described form must not be the place
205 // that quietly changes where a password lives.
206 let state = state().await;
207 post(&state, "/settings/email", submission(&[]));
208
209 let stored = state.email_accounts.list_by_user(DESKTOP_USER_ID).unwrap();
210 assert_eq!(stored[0].password, "");
211 }
212
213 #[tokio::test]
214 async fn a_nameless_account_is_refused_and_the_typing_survives() {
215 let state = state().await;
216 let page = html(post(
217 &state,
218 "/settings/email",
219 submission(&[("account_name", ""), ("username", "typed and nearly lost")]),
220 ));
221 assert!(page.contains("An account needs a name."), "{page}");
222 assert!(page.contains("typed and nearly lost"), "{page}");
223 assert!(
224 state
225 .email_accounts
226 .list_by_user(DESKTOP_USER_ID)
227 .unwrap()
228 .is_empty()
229 );
230 }
231
232 #[tokio::test]
233 async fn a_new_account_without_a_password_is_refused() {
234 let state = state().await;
235 let page = html(post(
236 &state,
237 "/settings/email",
238 submission(&[("password", "")]),
239 ));
240 assert!(page.contains("A new account needs a password."), "{page}");
241 }
242
243 #[tokio::test]
244 async fn a_folder_name_cannot_carry_a_second_imap_command() {
245 // The control-character check is what `create_email_account` refuses on,
246 // and it is not decoration: a folder name is interpolated into IMAP.
247 let state = state().await;
248 let page = html(post(
249 &state,
250 "/settings/email",
251 submission(&[("archive_folder_name", "Archive\r\nLOGOUT")]),
252 ));
253 assert!(page.contains("control characters"), "{page}");
254 assert!(
255 state
256 .email_accounts
257 .list_by_user(DESKTOP_USER_ID)
258 .unwrap()
259 .is_empty()
260 );
261 }
262
263 #[tokio::test]
264 async fn editing_an_account_saves_what_changed() {
265 let state = state().await;
266 let existing = account(&state, "Old name");
267
268 post(
269 &state,
270 &format!("/settings/email/{}", existing.id),
271 submission(&[
272 ("account_name", "New name"),
273 ("advanced", "1"),
274 ("imap_server", "imap.elsewhere.com"),
275 ]),
276 );
277
278 let stored = state
279 .email_accounts
280 .get_by_id(existing.id, DESKTOP_USER_ID)
281 .unwrap()
282 .expect("still there");
283 assert_eq!(stored.account_name, "New name");
284 assert_eq!(stored.imap_server, "imap.elsewhere.com");
285 }
286
287 #[tokio::test]
288 async fn a_closed_advanced_block_does_not_blank_the_servers() {
289 // The submission carries no server fields when the block is shut. Reading
290 // them as empty would fail validation on four required columns the user
291 // never touched -- or worse, store the blanks.
292 let state = state().await;
293 let existing = account(&state, "Personal");
294
295 let mut params = Params::new()
296 .with("account_name", "Renamed")
297 .with("email_address", "someone@example.com")
298 .with("username", "someone@example.com")
299 .with("archive_folder_name", "Archive");
300 params = params.with("password", "");
301
302 post(&state, &format!("/settings/email/{}", existing.id), params);
303
304 let stored = state
305 .email_accounts
306 .get_by_id(existing.id, DESKTOP_USER_ID)
307 .unwrap()
308 .expect("still there");
309 assert_eq!(stored.account_name, "Renamed");
310 assert_eq!(stored.imap_server, "imap.example.com");
311 assert_eq!(stored.imap_port, 993);
312 assert_eq!(stored.smtp_server, "smtp.example.com");
313 }
314
315 #[tokio::test]
316 async fn the_advanced_disclosure_is_an_address() {
317 // `?advanced=1`, not a toggle button holding module state. Same answer the
318 // project dashboard's completed milestones got.
319 let state = state().await;
320 let existing = account(&state, "Personal");
321
322 let shut = html(get(
323 &state,
324 &format!("/settings/email/{}/edit", existing.id),
325 Params::new(),
326 ));
327 assert!(!shut.contains("name=\"imap_server\""), "{shut}");
328
329 let open = html(get(
330 &state,
331 &format!("/settings/email/{}/edit", existing.id),
332 Params::new().with("advanced", "1"),
333 ));
334 assert!(open.contains("name=\"imap_server\""), "{open}");
335 }
336
337 #[tokio::test]
338 async fn deleting_an_account_takes_it_out_of_the_section() {
339 let state = state().await;
340 let going = account(&state, "Going");
341
342 let page = html(post(
343 &state,
344 &format!("/settings/email/{}/delete", going.id),
345 Params::new(),
346 ));
347 assert!(!page.contains("Going"), "{page}");
348 assert!(
349 state
350 .email_accounts
351 .list_by_user(DESKTOP_USER_ID)
352 .unwrap()
353 .is_empty()
354 );
355 }
356
357 #[tokio::test]
358 async fn an_account_that_is_not_there_is_a_not_found() {
359 let state = state().await;
360 let error = router()
361 .handle(
362 &state,
363 Request::get(format!(
364 "/settings/email/{}/edit",
365 EmailAccountId::from(uuid::Uuid::nil())
366 )),
367 )
368 .expect_err("no such account");
369 assert_eq!(error.class.http_status(), 404);
370 }
371