From 2afbc89d3ecaf71608cae670463fe0554296560b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20DOUIN?= Date: Tue, 31 Mar 2026 07:18:54 +0200 Subject: [PATCH] tests: add fastmail and stalwart integration tests --- .github/workflows/fastmail-jmap-tests.yml | 17 ++++++ .github/workflows/stalwart-jmap-tests.yml | 25 ++++++++ Cargo.lock | 34 +++++------ src/config.rs | 6 +- src/jmap/identity/create.rs | 25 ++++---- tests/common/jmap.rs | 71 ++++++++++------------- tests/fastmail-jmap.rs | 4 +- tests/stalwart-jmap.rs | 28 +++++++++ tests/stalwart.sh | 13 +++++ 9 files changed, 151 insertions(+), 72 deletions(-) create mode 100644 .github/workflows/fastmail-jmap-tests.yml create mode 100644 .github/workflows/stalwart-jmap-tests.yml create mode 100644 tests/stalwart-jmap.rs create mode 100755 tests/stalwart.sh diff --git a/.github/workflows/fastmail-jmap-tests.yml b/.github/workflows/fastmail-jmap-tests.yml new file mode 100644 index 00000000..d74ec5c9 --- /dev/null +++ b/.github/workflows/fastmail-jmap-tests.yml @@ -0,0 +1,17 @@ +name: Fastmail JMAP tests + +on: + push: + branches: + - v2 + +jobs: + fastmail-jmap-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: cargo test --no-default-features --features jmap,rustls-ring --test fastmail-jmap -- --ignored + env: + EMAIL: ${{ secrets.FASTMAIL_EMAIL }} + BEARER_TOKEN: ${{ secrets.FASTMAIL_BEARER_TOKEN }} diff --git a/.github/workflows/stalwart-jmap-tests.yml b/.github/workflows/stalwart-jmap-tests.yml new file mode 100644 index 00000000..b91096c2 --- /dev/null +++ b/.github/workflows/stalwart-jmap-tests.yml @@ -0,0 +1,25 @@ +name: Stalwart JMAP tests + +on: + push: + branches: + - v2 + +jobs: + stalwart-jmap-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - run: docker run -d --rm --name stalwart-test-for-himalaya -p 8080:8080 stalwartlabs/stalwart:latest-alpine + - run: sleep 1 + - run: | + echo "ADMIN_PASSWORD=$(docker logs stalwart-test-for-himalaya 2>&1 | grep -oP "(?<=with password ')[^']+")" >> $GITHUB_ENV + - run: | + curl -u "admin:${ADMIN_PASSWORD}" -X POST -H 'Content-Type: application/json' -d '{"type":"domain","name":"pimalaya.org","description":"","quota":0,"secrets":[],"emails":[],"urls":[],"memberOf":[],"roles":[],"lists":[],"members":[],"enabledPermissions":[],"disabledPermissions":[],"externalMembers":[]}' http://localhost:8080/api/principal + - run: | + curl -u "admin:${ADMIN_PASSWORD}" -X POST -H 'Content-Type: application/json' -d '{"type":"individual","name":"test","description":"","quota":0,"secrets":["test"],"emails":["test@pimalaya.org"],"memberOf":[],"roles":["user"],"lists":[],"enabledPermissions":[],"disabledPermissions":[],"externalMembers":[]}' http://localhost:8080/api/principal + - run: cargo test --no-default-features --features jmap,rustls-ring --test stalwart-jmap -- --ignored + env: + EMAIL: test@pimalaya.org + - run: docker stop stalwart-test-for-himalaya diff --git a/Cargo.lock b/Cargo.lock index b56bc9fd..15422c9e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,9 +1039,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.93" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "797146bb2677299a1eb6b7b50a890f4c361b29ef967addf5b2fa45dae1bb6d7d" dependencies = [ "once_cell", "wasm-bindgen", @@ -1338,7 +1338,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pimalaya-toolbox" version = "0.0.4" -source = "git+https://github.com/pimalaya/toolbox#31f516ca913221b7dc2a4c10711c04132242737f" +source = "git+https://github.com/pimalaya/toolbox#94a3d4fcbbd8a23483384e759dbb889110c0c082" dependencies = [ "anyhow", "base64", @@ -1617,9 +1617,9 @@ dependencies = [ [[package]] name = "roff" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbf2048e0e979efb2ca7b91c4f1a8d77c91853e9b987c94c555668a8994915ad" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" [[package]] name = "rustix" @@ -2191,9 +2191,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "7dc0882f7b5bb01ae8c5215a1230832694481c1a4be062fd410e12ea3da5b631" dependencies = [ "cfg-if", "once_cell", @@ -2204,9 +2204,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "75973d3066e01d035dbedaad2864c398df42f8dd7b1ea057c35b8407c015b537" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2214,9 +2214,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "91af5e4be765819e0bcfee7322c14374dc821e35e72fa663a830bbc7dc199eac" dependencies = [ "bumpalo", "proc-macro2", @@ -2227,9 +2227,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "c9bf0406a78f02f336bf1e451799cca198e8acde4ffa278f0fb20487b150a633" dependencies = [ "unicode-ident", ] @@ -2599,18 +2599,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/src/config.rs b/src/config.rs index 886b0135..877ba4ab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -289,8 +289,11 @@ pub struct JmapConfig { #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub enum JmapAuthConfig { + Header(Secret), /// Bearer token (OAuth 2.0 access token). - Bearer { token: Secret }, + Bearer { + token: Secret, + }, /// HTTP Basic authentication (username + password). Basic { #[serde(deserialize_with = "shell_expanded_string")] @@ -305,6 +308,7 @@ impl TryFrom for pimalaya_toolbox::stream::jmap::JmapAuth { fn try_from(config: JmapAuthConfig) -> Result { match config { + JmapAuthConfig::Header(token) => Ok(Self::Header(token.get()?)), JmapAuthConfig::Bearer { token } => Ok(Self::Bearer(token.get()?)), JmapAuthConfig::Basic { username, password } => Ok(Self::Basic { username, diff --git a/src/jmap/identity/create.rs b/src/jmap/identity/create.rs index d5a36e2b..3df9ec5d 100644 --- a/src/jmap/identity/create.rs +++ b/src/jmap/identity/create.rs @@ -1,4 +1,4 @@ -use anyhow::{anyhow, bail, Result}; +use anyhow::{bail, Result}; use clap::Parser; use io_jmap::{ rfc8621::coroutines::identity_set::{ @@ -50,7 +50,7 @@ impl JmapIdentityCreateCommand { let mut coroutine = JmapIdentitySet::new(&jmap.session, &jmap.http_auth, args)?; let mut arg = None; - let errs = loop { + let not_created = loop { match coroutine.resume(arg.take()) { JmapIdentitySetResult::Io { io } => arg = Some(handle(&mut jmap.stream, io)?), JmapIdentitySetResult::Ok { not_created, .. } => break not_created, @@ -58,19 +58,22 @@ impl JmapIdentityCreateCommand { } }; - if let Some(err) = errs.get(create_id) { - let mut ctx = anyhow!("Create identity for `{}` error", &self.email); - - if let Some(desc) = &err.description { - ctx = anyhow!("{desc}").context(ctx); - } + if let Some(err) = not_created.get(create_id) { + let mut msg = format!("Create identity for `{}` error", self.email); if !err.properties.is_empty() { - let props = err.properties.join(", "); - ctx = anyhow!("Invalid properties {props}").context(ctx); + msg.push_str(": invalid propertie(s) `"); + msg.push_str(&err.properties.join("`, `")); + msg.push('`'); } - bail!(ctx); + if let Some(desc) = &err.description { + msg.push_str(" ("); + msg.push_str(desc.to_lowercase().trim_end_matches(['.', '\n'])); + msg.push_str(")"); + } + + bail!(msg); } printer.out(Message::new("Identity successfully created")) diff --git a/tests/common/jmap.rs b/tests/common/jmap.rs index bb72dbc2..1e5b1cb6 100644 --- a/tests/common/jmap.rs +++ b/tests/common/jmap.rs @@ -12,18 +12,6 @@ use io_jmap::rfc8621::types::{ use serde::de::DeserializeOwned; use serde_json::Value; -/// Minimal RFC 5322 message used as the email fixture throughout the suite. -const EML: &str = concat!( - "From: Himalaya Test \r\n", - "To: Himalaya Test \r\n", - "Subject: Himalaya integration test\r\n", - "Date: Thu, 01 Jan 2026 00:00:00 +0000\r\n", - "MIME-Version: 1.0\r\n", - "Content-Type: text/plain; charset=utf-8\r\n", - "\r\n", - "This is a test email for himalaya integration tests.\r\n", -); - /// Resources to clean up after the test, even on failure. struct Cleanup<'a> { config: &'a Path, @@ -85,6 +73,8 @@ fn parse_output(config: &Path, args: &[&str]) -> T { /// Exercises every command in a single ordered flow. Pass a path to a /// valid TOML config file with a default JMAP account configured. pub fn run(config: &Path) { + let email = env::var("EMAIL").expect("EMAIL env var"); + let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() @@ -160,10 +150,22 @@ pub fn run(config: &Path) { // ── 2. EMAILS ───────────────────────────────────────────────────────── + let eml = [ + &format!("From: Himalaya Test <{email}>"), + &format!("To: Himalaya Test <{email}>"), + "Subject: Himalaya integration test", + "Date: Thu, 01 Jan 2026 00:00:00 +0000", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=utf-8", + "", + "This is a test email for himalaya integration tests.", + ] + .join("\r\n"); + // import from stdin jmap(config) .args(["emails", "import", "--mailbox-id", &mbox_id]) - .write_stdin(EML) + .write_stdin(eml.as_bytes()) .assert() .success(); @@ -292,7 +294,7 @@ pub fn run(config: &Path) { // import --upload-only: upload blob and get its id let stdout = jmap(config) .args(["emails", "import", "--upload-only"]) - .write_stdin(EML) + .write_stdin(eml) .assert() .success() .get_output() @@ -333,60 +335,47 @@ pub fn run(config: &Path) { // ── 4. IDENTITY ─────────────────────────────────────────────────────── - // list all identities - let identities: Vec = parse_output(config, &["identity", "get"]); - assert!(!identities.is_empty(), "expected at least one identity"); - - let primary_identity_id = identities[0].id.clone(); - let identity_email = identities[0].email.clone(); - - // create a new identity + // create jmap(config) .args([ "identity", "create", - "Himalaya Test Identity", - &identity_email, + "Test", + &email, "--text-signature", "Sent by himalaya integration tests", ]) .assert() .success(); - // list again — find by name and verify signature field + // list — find by name and verify signature field let identities: Vec = parse_output(config, &["identity", "get"]); - let new_identity = identities + let identity = identities .iter() - .find(|i| i.name == "Himalaya Test Identity") + .find(|i| i.name == "Test") .expect("created identity not found in list"); assert_eq!( - new_identity.text_signature.as_deref(), + identity.text_signature.as_deref(), Some("Sent by himalaya integration tests"), "identity textSignature mismatch after create" ); - let identity_id = new_identity.id.clone(); + let identity_id = identity.id.clone(); + let identity_email = identity.email.clone(); cleanup.identity_id = Some(identity_id.clone()); - // update: rename — then verify the new name appears in the list + // update: rename jmap(config) - .args([ - "identity", - "update", - &identity_id, - "--name", - "Himalaya Test Identity Updated", - ]) + .args(["identity", "update", &identity_id, "--name", "Test Updated"]) .assert() .success(); + // list — verify rename let identities: Vec = parse_output(config, &["identity", "get"]); assert!( - identities - .iter() - .any(|i| i.name == "Himalaya Test Identity Updated"), + identities.iter().any(|i| i.name == "Test Updated"), "updated identity name not found in list" ); @@ -442,7 +431,7 @@ pub fn run(config: &Path) { "create", &draft_id, "--identity-id", - &primary_identity_id, + &identity_id, ], ); diff --git a/tests/fastmail-jmap.rs b/tests/fastmail-jmap.rs index 6076316b..394798d9 100644 --- a/tests/fastmail-jmap.rs +++ b/tests/fastmail-jmap.rs @@ -6,9 +6,9 @@ use std::{env, io::Write}; use tempfile::NamedTempFile; #[test] -#[ignore = "requires FASTMAIL_API_TOKEN env var and --ignored"] +#[ignore = "requires BEARER_TOKEN env var and --ignored"] fn fastmail_jmap() { - let token = env::var("FASTMAIL_API_TOKEN").expect("FASTMAIL_API_TOKEN env var"); + let token = env::var("BEARER_TOKEN").expect("BEARER_TOKEN env var"); let mut config = NamedTempFile::new().unwrap(); let config_tpl = format!( diff --git a/tests/stalwart-jmap.rs b/tests/stalwart-jmap.rs new file mode 100644 index 00000000..380f294c --- /dev/null +++ b/tests/stalwart-jmap.rs @@ -0,0 +1,28 @@ +#[path = "common/jmap.rs"] +mod jmap; + +use std::{env, io::Write}; + +use tempfile::NamedTempFile; + +#[test] +#[ignore = "requires URL, USER, PASS env vars and --ignored"] +fn stalwart_jmap() { + let mut config = NamedTempFile::new().unwrap(); + + let url = env::var("URL").unwrap_or("http://localhost:8080/jmap/session".into()); + let user = env::var("USER").unwrap_or("test".into()); + let pass = env::var("PASS").unwrap_or("test".into()); + + let config_tpl = format!( + r#"[accounts.stalwart] +default = true +jmap.server = "{url}" +jmap.auth.basic.username = "{user}" +jmap.auth.basic.password.raw = "{pass}""# + ); + + config.write(&config_tpl.into_bytes()).unwrap(); + + jmap::run(config.path()); +} diff --git a/tests/stalwart.sh b/tests/stalwart.sh new file mode 100755 index 00000000..39aa3dbd --- /dev/null +++ b/tests/stalwart.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -e + +docker run -d --rm --name stalwart-test-for-himalaya -p 8080:8080 stalwartlabs/stalwart:latest-alpine + +sleep 1 + +admin_password=$(docker logs stalwart-test-for-himalaya 2>&1 | grep -oP "(?<=with password ')[^']+") + +curl -u "admin:${admin_password}" -X POST -H 'Content-Type: application/json' -d '{"type":"domain","name":"pimalaya.org","description":"","quota":0,"secrets":[],"emails":[],"urls":[],"memberOf":[],"roles":[],"lists":[],"members":[],"enabledPermissions":[],"disabledPermissions":[],"externalMembers":[]}' http://localhost:8080/api/principal + +curl -u "admin:${admin_password}" -X POST -H 'Content-Type: application/json' -d '{"type":"individual","name":"test","description":"","quota":0,"secrets":["test"],"emails":["test@pimalaya.org"],"memberOf":[],"roles":["user"],"lists":[],"enabledPermissions":[],"disabledPermissions":[],"externalMembers":[]}' http://localhost:8080/api/principal