This commit is contained in:
mdecimus
2023-11-01 12:08:24 +01:00
parent ee088d5184
commit df45384fcd
94 changed files with 3024 additions and 489 deletions

509
tests/src/jmap/blob.rs Normal file
View File

@@ -0,0 +1,509 @@
/*
* Copyright (c) 2023 Stalwart Labs Ltd.
*
* This file is part of Stalwart Mail Server.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of
* the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* in the LICENSE file at the top-level directory of this distribution.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* You can be released from the requirements of the AGPLv3 license by
* purchasing a commercial license. Please contact licensing@stalw.art
* for more details.
*/
use std::sync::Arc;
use jmap::{mailbox::INBOX_ID, JMAP};
use jmap_client::client::Client;
use jmap_proto::types::id::Id;
use serde_json::Value;
use crate::{
directory::sql::create_test_user_with_email,
jmap::{jmap_json_request, mailbox::destroy_all_mailboxes},
};
pub async fn test(server: Arc<JMAP>, admin_client: &mut Client) {
println!("Running blob tests...");
let directory = server.directory.as_ref();
create_test_user_with_email(directory, "jdoe@example.com", "12345", "John Doe").await;
let account_id = Id::from(server.get_account_id("jdoe@example.com").await.unwrap());
server
.store
.delete_account_blobs(account_id.document_id())
.await
.unwrap();
// Blob/set simple test
let response = jmap_json_request(
r#"[[
"Blob/upload",
{
"accountId": "$$",
"create": {
"abc": {
"data" : [
{
"data:asBase64": "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/AAAZ4gk3AAAAAXRSTlN/gFy0ywAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII="
}
],
"type": "image/png"
}
}
},
"R1"
]]"#
.replace("$$", &account_id.to_string()),
"jdoe@example.com",
"12345",
)
.await;
assert_eq!(
response
.pointer("/methodResponses/0/1/created/abc/type")
.and_then(|v| v.as_str())
.unwrap_or_default(),
"image/png",
"Response: {:?}",
response
);
assert_eq!(
response
.pointer("/methodResponses/0/1/created/abc/size")
.and_then(|v| v.as_i64())
.unwrap_or_default(),
95,
"Response: {:?}",
response
);
// Blob/get simple test
let blob_id = jmap_json_request(
r#"[[
"Blob/upload",
{
"accountId": "$$",
"create": {
"abc": {
"data" : [
{
"data:asText": "The quick brown fox jumped over the lazy dog."
}
]
}
}
},
"R1"
]]"#
.replace("$$", &account_id.to_string()),
"jdoe@example.com",
"12345",
)
.await
.pointer("/methodResponses/0/1/created/abc/id")
.and_then(|v| v.as_str())
.unwrap()
.to_string();
let response = jmap_json_request(
r#"[
[
"Blob/get",
{
"accountId" : "$$",
"ids" : [
"%%"
],
"properties" : [
"data:asText",
"digest:sha",
"size"
]
},
"R1"
],
[
"Blob/get",
{
"accountId" : "$$",
"ids" : [
"%%"
],
"properties" : [
"data:asText",
"digest:sha",
"digest:sha-256",
"size"
],
"offset" : 4,
"length" : 9
},
"R2"
]
]"#
.replace("$$", &account_id.to_string())
.replace("%%", &blob_id),
"jdoe@example.com",
"12345",
)
.await;
for (pointer, expected) in [
(
"/methodResponses/0/1/list/0/data:asText",
"The quick brown fox jumped over the lazy dog.",
),
(
"/methodResponses/0/1/list/0/digest:sha",
"wIVPufsDxBzOOALLDSIFKebu+U4=",
),
("/methodResponses/0/1/list/0/size", "45"),
("/methodResponses/1/1/list/0/data:asText", "quick bro"),
(
"/methodResponses/1/1/list/0/digest:sha",
"QiRAPtfyX8K6tm1iOAtZ87Xj3Ww=",
),
(
"/methodResponses/1/1/list/0/digest:sha-256",
"gdg9INW7lwHK6OQ9u0dwDz2ZY/gubi0En0xlFpKt0OA=",
),
] {
assert_eq!(
response
.pointer(pointer)
.and_then(|v| match v {
Value::String(s) => Some(s.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => None,
})
.unwrap_or_default(),
expected,
"Pointer {pointer:?} Response: {response:?}",
);
}
server
.store
.delete_account_blobs(account_id.document_id())
.await
.unwrap();
// Blob/upload Complex Example
let response = jmap_json_request(
r##"[
[
"Blob/upload",
{
"accountId" : "$$",
"create": {
"b4": {
"data": [
{
"data:asText": "The quick brown fox jumped over the lazy dog."
}
]
}
}
},
"S4"
],
[
"Blob/upload",
{
"accountId" : "$$",
"create": {
"cat": {
"data": [
{
"data:asText": "How"
},
{
"blobId": "#b4",
"length": 7,
"offset": 3
},
{
"data:asText": "was t"
},
{
"blobId": "#b4",
"length": 1,
"offset": 1
},
{
"data:asBase64": "YXQ/"
}
]
}
}
},
"CAT"
],
[
"Blob/get",
{
"accountId" : "$$",
"properties": [
"data:asText",
"size"
],
"ids": [
"#cat"
]
},
"G4"
]
]"##
.replace("$$", &account_id.to_string()),
"jdoe@example.com",
"12345",
)
.await;
for (pointer, expected) in [
(
"/methodResponses/2/1/list/0/data:asText",
"How quick was that?",
),
("/methodResponses/2/1/list/0/size", "19"),
] {
assert_eq!(
response
.pointer(pointer)
.and_then(|v| match v {
Value::String(s) => Some(s.to_string()),
Value::Number(n) => Some(n.to_string()),
_ => None,
})
.unwrap_or_default(),
expected,
"Pointer {pointer:?} Response: {response:?}",
);
}
server
.store
.delete_account_blobs(account_id.document_id())
.await
.unwrap();
// Blob/get Example with Range and Encoding Errors
let response = jmap_json_request(
r##"[
[
"Blob/upload",
{
"accountId" : "$$",
"create": {
"b1": {
"data": [
{
"data:asBase64": "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg=="
}
]
},
"b2": {
"data": [
{
"data:asText": "hello world"
}
],
"type" : "text/plain"
}
}
},
"S1"
],
[
"Blob/get",
{
"accountId" : "$$",
"ids": [
"#b1",
"#b2"
]
},
"G1"
],
[
"Blob/get",
{
"accountId" : "$$",
"ids": [
"#b1",
"#b2"
],
"properties": [
"data:asText",
"size"
]
},
"G2"
],
[
"Blob/get",
{
"accountId" : "$$",
"ids": [
"#b1",
"#b2"
],
"properties": [
"data:asBase64",
"size"
]
},
"G3"
],
[
"Blob/get",
{
"accountId" : "$$",
"offset": 0,
"length": 5,
"ids": [
"#b1",
"#b2"
]
},
"G4"
],
[
"Blob/get",
{
"accountId" : "$$",
"offset": 20,
"length": 100,
"ids": [
"#b1",
"#b2"
]
},
"G5"
]
]"##
.replace("$$", &account_id.to_string()),
"jdoe@example.com",
"12345",
)
.await;
for (pointer, expected) in [
(
"/methodResponses/1/1/list/0/data:asBase64",
"VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==",
),
("/methodResponses/1/1/list/1/data:asText", "hello world"),
("/methodResponses/2/1/list/0/isEncodingProblem", "true"),
("/methodResponses/2/1/list/1/data:asText", "hello world"),
(
"/methodResponses/3/1/list/0/data:asBase64",
"VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wZWQgb3ZlciB0aGUggYEgZG9nLg==",
),
(
"/methodResponses/3/1/list/1/data:asBase64",
"aGVsbG8gd29ybGQ=",
),
("/methodResponses/4/1/list/0/data:asText", "The q"),
("/methodResponses/4/1/list/1/data:asText", "hello"),
("/methodResponses/5/1/list/0/isEncodingProblem", "true"),
("/methodResponses/5/1/list/0/isTruncated", "true"),
("/methodResponses/5/1/list/1/isTruncated", "true"),
] {
assert_eq!(
response
.pointer(pointer)
.and_then(|v| match v {
Value::String(s) => Some(s.to_string()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
_ => None,
})
.unwrap_or_default(),
expected,
"Pointer {pointer:?} Response: {response:?}",
);
}
server
.store
.delete_account_blobs(account_id.document_id())
.await
.unwrap();
// Blob/lookup
admin_client.set_default_account_id(account_id.to_string());
let blob_id = admin_client
.email_import(
concat!(
"From: bill@example.com\r\n",
"To: jdoe@example.com\r\n",
"Subject: TPS Report\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
)
.as_bytes()
.to_vec(),
[&Id::from(INBOX_ID).to_string()],
None::<Vec<&str>>,
None,
)
.await
.unwrap()
.take_blob_id();
let response = jmap_json_request(
r#"[[
"Blob/lookup",
{
"accountId" : "$$",
"typeNames": [
"Mailbox",
"Thread",
"Email"
],
"ids": [
"%%",
"not-a-blob"
]
},
"R1"
]]"#
.replace("$$", &account_id.to_string())
.replace("%%", &blob_id),
"jdoe@example.com",
"12345",
)
.await;
for pointer in [
"/methodResponses/0/1/list/0/matchedIds/Email",
"/methodResponses/0/1/list/0/matchedIds/Mailbox",
"/methodResponses/0/1/list/0/matchedIds/Thread",
] {
assert_eq!(
response
.pointer(pointer)
.and_then(|v| v.as_array())
.map(|arr| arr.len())
.unwrap_or_default(),
1,
"Pointer {pointer:?} Response: {response:?}",
);
}
// Remove test data
admin_client.set_default_account_id(account_id.to_string());
destroy_all_mailboxes(admin_client).await;
server.store.assert_is_empty().await;
}

View File

@@ -23,10 +23,12 @@
use std::{sync::Arc, time::Duration};
use base64::{engine::general_purpose, Engine};
use directory::config::ConfigDirectory;
use jmap::{api::JmapSessionManager, services::IPC_CHANNEL_BUFFER, JMAP};
use jmap_client::client::{Client, Credentials};
use jmap_proto::types::id::Id;
use reqwest::header;
use smtp::core::{SmtpSessionManager, SMTP};
use tokio::sync::{mpsc, watch};
use utils::{config::ServerProtocol, UnwrapFailure};
@@ -40,6 +42,7 @@ use crate::{
pub mod auth_acl;
pub mod auth_limits;
pub mod auth_oauth;
pub mod blob;
pub mod crypto;
pub mod delivery;
pub mod email_changes;
@@ -219,12 +222,12 @@ refresh-token-renew = "2s"
#[tokio::test]
pub async fn jmap_tests() {
tracing::subscriber::set_global_default(
/*tracing::subscriber::set_global_default(
tracing_subscriber::FmtSubscriber::builder()
.with_max_level(tracing::Level::WARN)
.finish(),
)
.unwrap();
.unwrap();*/
let delete = true;
let mut params = init_jmap_tests(delete).await;
@@ -251,6 +254,7 @@ pub async fn jmap_tests() {
websocket::test(params.server.clone(), &mut params.client).await;
quota::test(params.server.clone(), &mut params.client).await;
crypto::test(params.server.clone(), &mut params.client).await;
blob::test(params.server.clone(), &mut params.client).await;
if delete {
params.temp_dir.delete();
@@ -338,6 +342,51 @@ async fn init_jmap_tests(delete_if_exists: bool) -> JMAPTest {
}
}
pub async fn jmap_raw_request(body: impl AsRef<str>, username: &str, secret: &str) -> String {
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!(
"Basic {}",
general_purpose::STANDARD.encode(format!("{}:{}", username, secret))
))
.unwrap(),
);
const BODY_TEMPLATE: &str = r#"{
"using": [ "urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:quota" ],
"methodCalls": $$
}"#;
String::from_utf8(
reqwest::Client::builder()
.danger_accept_invalid_certs(true)
.timeout(Duration::from_millis(1000))
.default_headers(headers)
.build()
.unwrap()
.post("https://127.0.0.1:8899/jmap")
.body(BODY_TEMPLATE.replace("$$", body.as_ref()))
.send()
.await
.unwrap()
.bytes()
.await
.unwrap()
.to_vec(),
)
.unwrap()
}
pub async fn jmap_json_request(
body: impl AsRef<str>,
username: &str,
secret: &str,
) -> serde_json::Value {
serde_json::from_str(&jmap_raw_request(body, username, secret).await).unwrap()
}
pub fn find_values(string: &str, name: &str) -> Vec<String> {
let mut last_pos = 0;
let mut values = Vec::new();

View File

@@ -44,7 +44,7 @@ use jmap::{
JMAP,
};
use jmap_client::{client::Client, mailbox::Role, push_subscription::Keys};
use jmap_proto::types::{id::Id, type_state::TypeState};
use jmap_proto::types::{id::Id, type_state::DataType};
use reqwest::header::CONTENT_ENCODING;
use store::ahash::AHashSet;
use tokio::{net::TcpStream, sync::mpsc};
@@ -138,7 +138,7 @@ pub async fn test(server: Arc<JMAP>, admin_client: &mut Client) {
.unwrap()
.take_id();
assert_state(&mut event_rx, &account_id, &[TypeState::Mailbox]).await;
assert_state(&mut event_rx, &account_id, &[DataType::Mailbox]).await;
// Receive states just for the requested types
client
@@ -192,14 +192,14 @@ pub async fn test(server: Arc<JMAP>, admin_client: &mut Client) {
.unwrap();
tokio::time::sleep(Duration::from_millis(200)).await;
push_server.fail_requests.store(false, Ordering::Relaxed);
assert_state(&mut event_rx, &account_id, &[TypeState::Mailbox]).await;
assert_state(&mut event_rx, &account_id, &[DataType::Mailbox]).await;
// Make a mailbox change and expect state change
client
.mailbox_rename(&mailbox_id, "My Mailbox")
.await
.unwrap();
assert_state(&mut event_rx, &account_id, &[TypeState::Mailbox]).await;
assert_state(&mut event_rx, &account_id, &[DataType::Mailbox]).await;
//expect_nothing(&mut event_rx).await;
// Multiple change updates should be grouped and pushed in intervals
@@ -209,7 +209,7 @@ pub async fn test(server: Arc<JMAP>, admin_client: &mut Client) {
.await
.unwrap();
}
assert_state(&mut event_rx, &account_id, &[TypeState::Mailbox]).await;
assert_state(&mut event_rx, &account_id, &[DataType::Mailbox]).await;
expect_nothing(&mut event_rx).await;
// Destroy mailbox
@@ -365,7 +365,7 @@ async fn expect_nothing(event_rx: &mut mpsc::Receiver<PushMessage>) {
}
}
async fn assert_state(event_rx: &mut mpsc::Receiver<PushMessage>, id: &Id, state: &[TypeState]) {
async fn assert_state(event_rx: &mut mpsc::Receiver<PushMessage>, id: &Id, state: &[DataType]) {
assert_eq!(
expect_push(event_rx)
.await
@@ -375,8 +375,8 @@ async fn assert_state(event_rx: &mut mpsc::Receiver<PushMessage>, id: &Id, state
.unwrap()
.iter()
.map(|x| x.0)
.collect::<AHashSet<&TypeState>>(),
state.iter().collect::<AHashSet<&TypeState>>()
.collect::<AHashSet<&DataType>>(),
state.iter().collect::<AHashSet<&DataType>>()
);
}

View File

@@ -33,7 +33,10 @@ use jmap_proto::types::{collection::Collection, id::Id};
use crate::{
directory::sql::{add_to_group, create_test_user_with_email, set_test_quota},
jmap::{delivery::SmtpConnection, mailbox::destroy_all_mailboxes, test_account_login},
jmap::{
delivery::SmtpConnection, jmap_raw_request, mailbox::destroy_all_mailboxes,
test_account_login,
},
};
pub async fn test(server: Arc<JMAP>, admin_client: &mut Client) {
@@ -110,6 +113,26 @@ pub async fn test(server: Arc<JMAP>, admin_client: &mut Client) {
.await
.unwrap();
// Test JMAP Quotas extension
let response = jmap_raw_request(
r#"[[ "Quota/get", {
"accountId": "$$",
"ids": null
}, "0" ]]"#
.replace("$$", &account_id.to_string()),
"robert@example.com",
"aabbcc",
)
.await;
assert!(response.contains("\"used\":0"), "{}", response);
assert!(response.contains("\"hardLimit\":1024"), "{}", response);
assert!(response.contains("\"scope\":\"account\""), "{}", response);
assert!(
response.contains("\"name\":\"robert@example.com\""),
"{}",
response
);
// Test Email/import quota
let inbox_id = Id::new(INBOX_ID as u64).to_string();
let mut message_ids = Vec::new();
@@ -143,6 +166,20 @@ pub async fn test(server: Arc<JMAP>, admin_client: &mut Client) {
.await,
);
// Test JMAP Quotas extension
let response = jmap_raw_request(
r#"[[ "Quota/get", {
"accountId": "$$",
"ids": null
}, "0" ]]"#
.replace("$$", &account_id.to_string()),
"robert@example.com",
"aabbcc",
)
.await;
assert!(response.contains("\"used\":1024"), "{}", response);
assert!(response.contains("\"hardLimit\":1024"), "{}", response);
// Delete messages and check available quota
for message_id in message_ids {
client.email_destroy(&message_id).await.unwrap();