oxedyne/fe2o3/fe2o3_mail/tests/imap_roundtrip.rs
8.7 KiB, 3 runs
created by r1870400018:13338, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | //! The IMAP client and the IMAP server, talking to each other. |
| 2 | //! |
| 3 | //! Neither side is mocked: a real [`MaildirStore`] on disk, the real |
| 4 | //! [`ImapServer`] over a real loopback socket, and the real |
| 5 | //! [`ImapClient`] reading it back. What is being tested is the wire -- |
| 6 | //! literals, flags, UID sets, sequence numbers -- because that is where an |
| 7 | //! IMAP implementation is either right or subtly, silently wrong. |
| 8 | //! |
| 9 | //! The messages deliberately include one whose body contains a bare CRLF |
| 10 | //! and a `)`, which is exactly what breaks a client that parses FETCH |
| 11 | //! responses line by line. |
| 12 | |
| 13 | use oxedyne_fe2o3_core::prelude::*; |
| 14 | use oxedyne_fe2o3_mail::maildir::MaildirStore; |
| 15 | use oxedyne_fe2o3_net::{ |
| 16 | imap::{ |
| 17 | client::{ |
| 18 | FetchWhat, |
| 19 | FlagOp, |
| 20 | ImapClient, |
| 21 | ImapConfig, |
| 22 | Security, |
| 23 | }, |
| 24 | server::ImapServer, |
| 25 | }, |
| 26 | mail::{ |
| 27 | store::{ |
| 28 | FolderName, |
| 29 | MailStore, |
| 30 | MailUser, |
| 31 | MessageFlags, |
| 32 | }, |
| 33 | user::UserStore, |
| 34 | }, |
| 35 | }; |
| 36 | |
| 37 | use std::{ |
| 38 | fs, |
| 39 | path::PathBuf, |
| 40 | time::Duration, |
| 41 | }; |
| 42 | |
| 43 | use tokio::net::TcpListener; |
| 44 | |
| 45 | |
| 46 | const USER: &str = "alice@test.local"; |
| 47 | const PASS: &str = "correct-horse"; |
| 48 | |
| 49 | /// A one-user store, so the test exercises the protocol rather than a |
| 50 | /// password file. |
| 51 | #[derive(Clone, Debug)] |
| 52 | struct OneUser { |
| 53 | /// Where this user's Maildir lives. |
| 54 | delivery_key: String, |
| 55 | } |
| 56 | |
| 57 | impl UserStore for OneUser { |
| 58 | fn authenticate(&self, address: &str, password: &str) -> Outcome<Option<MailUser>> { |
| 59 | if address.eq_ignore_ascii_case(USER) && password == PASS { |
| 60 | return Ok(self.lookup(address).ok().flatten()); |
| 61 | } |
| 62 | Ok(None) |
| 63 | } |
| 64 | |
| 65 | fn lookup(&self, address: &str) -> Outcome<Option<MailUser>> { |
| 66 | if !address.eq_ignore_ascii_case(USER) { |
| 67 | return Ok(None); |
| 68 | } |
| 69 | Ok(Some(MailUser { |
| 70 | local: fmt!("alice"), |
| 71 | domain: fmt!("test.local"), |
| 72 | delivery_key: self.delivery_key.clone(), |
| 73 | send_as: Vec::new(), |
| 74 | })) |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /// A message whose body would break a line-oriented parser. |
| 79 | fn awkward_message() -> Vec<u8> { |
| 80 | let mut m = Vec::new(); |
| 81 | m.extend_from_slice(b"From: bob@example.org\r\n"); |
| 82 | m.extend_from_slice(b"To: alice@test.local\r\n"); |
| 83 | m.extend_from_slice(b"Subject: literals\r\n"); |
| 84 | m.extend_from_slice(b"\r\n"); |
| 85 | m.extend_from_slice(b"A body with a blank line,\r\n"); |
| 86 | m.extend_from_slice(b"\r\n"); |
| 87 | m.extend_from_slice(b") a stray close paren, and {17} a fake literal.\r\n"); |
| 88 | m |
| 89 | } |
| 90 | |
| 91 | fn plain_message(n: usize) -> Vec<u8> { |
| 92 | fmt!("From: bob@example.org\r\n\ |
| 93 | To: alice@test.local\r\n\ |
| 94 | Subject: message {}\r\n\ |
| 95 | \r\n\ |
| 96 | Body of message {}.\r\n", n, n).into_bytes() |
| 97 | } |
| 98 | |
| 99 | #[tokio::test] |
| 100 | async fn imap_client_reads_what_the_imap_server_serves() -> Outcome<()> { |
| 101 | |
| 102 | // ── A Maildir with three messages in it ────────────────────── |
| 103 | let root = std::env::temp_dir().join(fmt!("fe2o3_imap_rt_{}", std::process::id())); |
| 104 | let _ = fs::remove_dir_all(&root); |
| 105 | res!(fs::create_dir_all(&root), IO, File); |
| 106 | |
| 107 | let store = res!(MaildirStore::new(PathBuf::from(&root), "test.local")); |
| 108 | let users = OneUser { delivery_key: fmt!("test.local/alice") }; |
| 109 | let user = match res!(users.lookup(USER)) { |
| 110 | Some(u) => u, |
| 111 | None => return Err(err!("The test user did not resolve."; Test, Bug)), |
| 112 | }; |
| 113 | res!(store.ensure_user(&user)); |
| 114 | |
| 115 | let inbox = FolderName::new("INBOX"); |
| 116 | let bodies = vec![plain_message(1), awkward_message(), plain_message(3)]; |
| 117 | for body in &bodies { |
| 118 | res!(store.append(&user, &inbox, body, MessageFlags::default(), None)); |
| 119 | } |
| 120 | |
| 121 | // ── The server, on a loopback socket ───────────────────────── |
| 122 | let listener = res!(TcpListener::bind("127.0.0.1:0").await, IO, Network); |
| 123 | let addr = res!(listener.local_addr(), IO, Network); |
| 124 | let server = ImapServer { |
| 125 | store: store.clone(), |
| 126 | users: users.clone(), |
| 127 | hostname: std::sync::Arc::new(fmt!("test.local")), |
| 128 | }; |
| 129 | tokio::spawn(async move { |
| 130 | while let Ok((sock, peer)) = listener.accept().await { |
| 131 | let srv = server.clone(); |
| 132 | tokio::spawn(async move { |
| 133 | let _ = srv.run(sock, peer).await; |
| 134 | }); |
| 135 | } |
| 136 | }); |
| 137 | |
| 138 | // ── The client ─────────────────────────────────────────────── |
| 139 | let cfg = ImapConfig::new(fmt!("127.0.0.1"), addr.port(), Security::Plain) |
| 140 | .with_timeout(Duration::from_secs(10)); |
| 141 | let mut c = res!(ImapClient::connect(&cfg).await); |
| 142 | assert!(c.has_cap("IMAP4REV1"), "the server did not advertise IMAP4rev1"); |
| 143 | |
| 144 | res!(c.login(USER, PASS).await); |
| 145 | |
| 146 | let folders = res!(c.list("", "*").await); |
| 147 | assert!(folders.iter().any(|f| f.name.eq_ignore_ascii_case("INBOX")), |
| 148 | "INBOX was not in the LIST reply: {:?}", folders); |
| 149 | |
| 150 | let st = res!(c.select("INBOX").await); |
| 151 | assert_eq!(st.exists, 3, "wrong message count"); |
| 152 | assert!(st.uid_validity > 0, "the server gave no UIDVALIDITY"); |
| 153 | assert!(st.uid_next > 3, "UIDNEXT should be past the messages we appended"); |
| 154 | |
| 155 | let uids = res!(c.uid_search("ALL").await); |
| 156 | assert_eq!(uids.len(), 3, "UID SEARCH ALL found {} of 3", uids.len()); |
| 157 | |
| 158 | // ── The whole point: the bytes come back byte-identical ────── |
| 159 | let msgs = res!(c.uid_fetch(&uids, FetchWhat::Full).await); |
| 160 | assert_eq!(msgs.len(), 3, "UID FETCH returned {} of 3", msgs.len()); |
| 161 | for (i, msg) in msgs.iter().enumerate() { |
| 162 | assert_eq!(msg.body, bodies[i], |
| 163 | "message {} came back changed:\n--- got ---\n{}\n--- want ---\n{}", |
| 164 | i, |
| 165 | String::from_utf8_lossy(&msg.body), |
| 166 | String::from_utf8_lossy(&bodies[i])); |
| 167 | assert_eq!(msg.size as usize, bodies[i].len(), "RFC822.SIZE disagrees"); |
| 168 | assert!(msg.uid > 0, "message {} came back with no UID", i); |
| 169 | } |
| 170 | |
| 171 | // Headers-only: the body stops at the blank line, and the size still |
| 172 | // describes the whole message. |
| 173 | let heads = res!(c.uid_fetch(&uids[..1], FetchWhat::Headers).await); |
| 174 | assert_eq!(heads.len(), 1); |
| 175 | let head = String::from_utf8_lossy(&heads[0].body).into_owned(); |
| 176 | assert!(head.contains("Subject: message 1"), "header block missing subject: {:?}", head); |
| 177 | assert!(!head.contains("Body of message 1"), "headers-only fetch returned the body"); |
| 178 | assert_eq!(heads[0].size as usize, bodies[0].len()); |
| 179 | |
| 180 | // ── Flags round-trip ───────────────────────────────────────── |
| 181 | res!(c.uid_store_flags(&uids[..1], FlagOp::Add, &["\\Seen"]).await); |
| 182 | let after = res!(c.uid_fetch(&uids[..1], FetchWhat::Meta).await); |
| 183 | assert!(after[0].flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen")), |
| 184 | "\\Seen did not stick: {:?}", after[0].flags); |
| 185 | |
| 186 | // ── Append, and see it arrive ──────────────────────────────── |
| 187 | let sent = plain_message(4); |
| 188 | res!(c.append("INBOX", &["\\Seen"], &sent).await); |
| 189 | let st = res!(c.select("INBOX").await); |
| 190 | assert_eq!(st.exists, 4, "the appended message did not land"); |
| 191 | |
| 192 | res!(c.logout().await); |
| 193 | let _ = fs::remove_dir_all(&root); |
| 194 | Ok(()) |
| 195 | } |
| 196 | |
| 197 | /// A wrong password must be a clean, named failure -- not a hang, and not |
| 198 | /// a success. |
| 199 | #[tokio::test] |
| 200 | async fn imap_client_reports_a_bad_password() -> Outcome<()> { |
| 201 | let root = std::env::temp_dir().join(fmt!("fe2o3_imap_auth_{}", std::process::id())); |
| 202 | let _ = fs::remove_dir_all(&root); |
| 203 | res!(fs::create_dir_all(&root), IO, File); |
| 204 | |
| 205 | let store = res!(MaildirStore::new(PathBuf::from(&root), "test.local")); |
| 206 | let users = OneUser { delivery_key: fmt!("test.local/alice") }; |
| 207 | if let Some(u) = res!(users.lookup(USER)) { |
| 208 | res!(store.ensure_user(&u)); |
| 209 | } |
| 210 | |
| 211 | let listener = res!(TcpListener::bind("127.0.0.1:0").await, IO, Network); |
| 212 | let addr = res!(listener.local_addr(), IO, Network); |
| 213 | let server = ImapServer { |
| 214 | store, |
| 215 | users, |
| 216 | hostname: std::sync::Arc::new(fmt!("test.local")), |
| 217 | }; |
| 218 | tokio::spawn(async move { |
| 219 | while let Ok((sock, peer)) = listener.accept().await { |
| 220 | let srv = server.clone(); |
| 221 | tokio::spawn(async move { let _ = srv.run(sock, peer).await; }); |
| 222 | } |
| 223 | }); |
| 224 | |
| 225 | let cfg = ImapConfig::new(fmt!("127.0.0.1"), addr.port(), Security::Plain) |
| 226 | .with_timeout(Duration::from_secs(10)); |
| 227 | let mut c = res!(ImapClient::connect(&cfg).await); |
| 228 | let outcome = c.login(USER, "wrong").await; |
| 229 | assert!(outcome.is_err(), "a wrong password authenticated"); |
| 230 | |
| 231 | let _ = fs::remove_dir_all(&root); |
| 232 | Ok(()) |
| 233 | } |