//! The email accounts section, driven through the router against a real database. use std::sync::Arc; use goingson_core::{EmailAccount, EmailAccountId, NewEmailAccount}; use quasi_http::Serves as _; use quasi_router::Outcome; use quasi_router::{Params, Request, Response}; use crate::quasi::router; use crate::state::{AppState, DESKTOP_USER_ID}; async fn state() -> Arc { let (state, _) = crate::test_utils::setup_test_state().await; let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); state .db .conn() .unwrap() .execute( "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ VALUES (?, ?, ?, ?, ?)", rusqlite::params![ DESKTOP_USER_ID.to_string(), "desktop@localhost", "x", "Desktop User", &now, ], ) .unwrap(); state } fn account(state: &AppState, name: &str) -> EmailAccount { state .email_accounts .create( DESKTOP_USER_ID, NewEmailAccount { account_name: name, email_address: "someone@example.com", imap_server: "imap.example.com", imap_port: 993, smtp_server: "smtp.example.com", smtp_port: 587, username: "someone@example.com", password: "", use_tls: true, archive_folder_name: Some("Archive"), }, ) .unwrap() } fn html(response: Response) -> String { match response.outcome { Outcome::Screen(screen) => quasi_webview::Webview::new().screen(&screen), Outcome::Fragment { node, .. } => quasi_webview::Webview::new().fragment(&node), Outcome::Goto(action) => panic!("expected content, got a redirect to {action:?}"), Outcome::Over(_) => panic!("expected content, got a screen drawn over it"), Outcome::Anchored { .. } => { panic!("expected content, got a screen drawn at a point on it") } Outcome::Suggestions { field, .. } => { panic!("expected content, got a suggestion list for `{field}`") } Outcome::File { name, .. } => panic!("expected content, got the file `{name}`"), Outcome::Locate(_) => panic!("expected content, got a place on a map"), // `cb62a9dc`. Work that runs somewhere else and a region that says so: // not content, and not a place either. Outcome::Started { region, .. } => { panic!("expected content, got work started in `{region}`") } } } fn get(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::get(path).carrying(params)) .expect("the route answers") } fn post(state: &AppState, path: &str, params: Params) -> Response { router() .handle(state, Request::post(path).sending(params)) .expect("the route answers") } fn section(state: &AppState) -> String { html(get(state, "/settings/email", Params::new())) } /// A complete, valid submission. Tests override the one field they are about. /// /// The override replaces the default rather than being appended after it. /// `Params::with` appends, and `get` answers with the first value, so building /// this by chaining defaults and then overrides would silently keep every /// default -- which is exactly what it did until 2026-08-21 and made four /// refusal tests pass a submission they were meant to refuse. fn submission(overrides: &[(&str, &str)]) -> Params { const DEFAULTS: [(&str, &str); 9] = [ ("account_name", "Personal"), ("email_address", "someone@example.com"), ("username", "someone@example.com"), ("password", "hunter2"), ("archive_folder_name", "Archive"), ("imap_server", "imap.example.com"), ("imap_port", "993"), ("smtp_server", "smtp.example.com"), ("smtp_port", "587"), ]; let mut params = Params::new(); for (name, default) in DEFAULTS { let value = overrides .iter() .find(|(over, _)| *over == name) .map_or(default, |(_, value)| *value); params = params.with(name, value); } // Anything the defaults do not name, such as `advanced`. for (name, value) in overrides { if !DEFAULTS.iter().any(|(known, _)| known == name) { params = params.with(*name, *value); } } params } #[tokio::test] async fn email_is_a_section_of_settings() { // The claim being corrected: settings.rs's header lists Email among the // sections that are "about the host rather than about the app". let state = state().await; let page = html(get(&state, "/settings", Params::new())); assert!(page.contains("Email"), "{page}"); assert!(page.contains("/settings/email"), "{page}"); } #[tokio::test] async fn an_empty_section_says_so_and_offers_the_form() { let state = state().await; let page = section(&state); assert!(page.contains("No accounts yet."), "{page}"); assert!(page.contains("/settings/email/new"), "{page}"); } #[tokio::test] async fn email_is_a_literal_rather_than_a_section_named_email() { // Mounted above `/settings/{section}`. Read the other way it would be a // section slug, and `section_of` has no such entry, so this would 404. let state = state().await; let form = html(get(&state, "/settings/email/new", Params::new())); assert!(form.contains("Add account"), "{form}"); } #[tokio::test] async fn the_form_asks_what_the_modal_asks() { let state = state().await; let form = html(get(&state, "/settings/email/new", Params::new())); for name in [ "account_name", "email_address", "username", "password", "archive_folder_name", "email_signature", "imap_server", "imap_port", "smtp_server", "smtp_port", ] { assert!(form.contains(&format!("name=\"{name}\"")), "{name}: {form}"); } } #[tokio::test] async fn the_password_is_a_secret_rather_than_text() { // `FieldKind::Secret` is the kind whose contract is that the value is never // echoed or round-tripped. A password in a `Text` field would render as one // anybody looking at the screen can read. let state = state().await; let form = html(get(&state, "/settings/email/new", Params::new())); assert!(form.contains("type=\"password\""), "{form}"); } #[tokio::test] async fn creating_an_account_puts_it_in_the_section() { let state = state().await; let page = html(post(&state, "/settings/email", submission(&[]))); assert!(page.contains("Personal"), "{page}"); let stored = state.email_accounts.list_by_user(DESKTOP_USER_ID).unwrap(); assert_eq!(stored.len(), 1); assert_eq!(stored[0].imap_server, "imap.example.com"); assert_eq!(stored[0].smtp_port, 587); } #[tokio::test] async fn the_password_never_reaches_the_row() { // `NewEmailAccount`'s own instruction: the column is written empty and the // secret goes to the OS keychain. A described form must not be the place // that quietly changes where a password lives. let state = state().await; post(&state, "/settings/email", submission(&[])); let stored = state.email_accounts.list_by_user(DESKTOP_USER_ID).unwrap(); assert_eq!(stored[0].password, ""); } #[tokio::test] async fn a_nameless_account_is_refused_and_the_typing_survives() { let state = state().await; let page = html(post( &state, "/settings/email", submission(&[("account_name", ""), ("username", "typed and nearly lost")]), )); assert!(page.contains("An account needs a name."), "{page}"); assert!(page.contains("typed and nearly lost"), "{page}"); assert!( state .email_accounts .list_by_user(DESKTOP_USER_ID) .unwrap() .is_empty() ); } #[tokio::test] async fn a_new_account_without_a_password_is_refused() { let state = state().await; let page = html(post( &state, "/settings/email", submission(&[("password", "")]), )); assert!(page.contains("A new account needs a password."), "{page}"); } #[tokio::test] async fn a_folder_name_cannot_carry_a_second_imap_command() { // The control-character check is what `create_email_account` refuses on, // and it is not decoration: a folder name is interpolated into IMAP. let state = state().await; let page = html(post( &state, "/settings/email", submission(&[("archive_folder_name", "Archive\r\nLOGOUT")]), )); assert!(page.contains("control characters"), "{page}"); assert!( state .email_accounts .list_by_user(DESKTOP_USER_ID) .unwrap() .is_empty() ); } #[tokio::test] async fn editing_an_account_saves_what_changed() { let state = state().await; let existing = account(&state, "Old name"); post( &state, &format!("/settings/email/{}", existing.id), submission(&[ ("account_name", "New name"), ("advanced", "1"), ("imap_server", "imap.elsewhere.com"), ]), ); let stored = state .email_accounts .get_by_id(existing.id, DESKTOP_USER_ID) .unwrap() .expect("still there"); assert_eq!(stored.account_name, "New name"); assert_eq!(stored.imap_server, "imap.elsewhere.com"); } #[tokio::test] async fn a_closed_advanced_block_does_not_blank_the_servers() { // The submission carries no server fields when the block is shut. Reading // them as empty would fail validation on four required columns the user // never touched -- or worse, store the blanks. let state = state().await; let existing = account(&state, "Personal"); let mut params = Params::new() .with("account_name", "Renamed") .with("email_address", "someone@example.com") .with("username", "someone@example.com") .with("archive_folder_name", "Archive"); params = params.with("password", ""); post(&state, &format!("/settings/email/{}", existing.id), params); let stored = state .email_accounts .get_by_id(existing.id, DESKTOP_USER_ID) .unwrap() .expect("still there"); assert_eq!(stored.account_name, "Renamed"); assert_eq!(stored.imap_server, "imap.example.com"); assert_eq!(stored.imap_port, 993); assert_eq!(stored.smtp_server, "smtp.example.com"); } #[tokio::test] async fn the_advanced_disclosure_is_an_address() { // `?advanced=1`, not a toggle button holding module state. Same answer the // project dashboard's completed milestones got. let state = state().await; let existing = account(&state, "Personal"); let shut = html(get( &state, &format!("/settings/email/{}/edit", existing.id), Params::new(), )); assert!(!shut.contains("name=\"imap_server\""), "{shut}"); let open = html(get( &state, &format!("/settings/email/{}/edit", existing.id), Params::new().with("advanced", "1"), )); assert!(open.contains("name=\"imap_server\""), "{open}"); } #[tokio::test] async fn deleting_an_account_takes_it_out_of_the_section() { let state = state().await; let going = account(&state, "Going"); let page = html(post( &state, &format!("/settings/email/{}/delete", going.id), Params::new(), )); assert!(!page.contains("Going"), "{page}"); assert!( state .email_accounts .list_by_user(DESKTOP_USER_ID) .unwrap() .is_empty() ); } #[tokio::test] async fn an_account_that_is_not_there_is_a_not_found() { let state = state().await; let error = router() .handle( &state, Request::get(format!( "/settings/email/{}/edit", EmailAccountId::from(uuid::Uuid::nil()) )), ) .expect_err("no such account"); assert_eq!(error.class.http_status(), 404); }