Registry testing - part 9

This commit is contained in:
Maurus Decimus
2026-03-19 19:29:17 +01:00
parent 7ae6654735
commit a67921f18c
57 changed files with 2886 additions and 1312 deletions

View File

@@ -1,175 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::sync::Arc;
use email::mailbox::{DRAFTS_ID, INBOX_ID, JUNK_ID};
use store::write::now;
use types::{id::Id, keyword::Keyword};
use crate::{imap::antispam::*, jmap::JMAPTest};
pub async fn test(test: &mut TestServer) {
println!("Running Email Spam classifier tests...");
let account = test.account("jdoe@example.com");
let client = account.jmap_client().await;
let account_id = account.id().document_id();
// Make sure there are no training samples
spam_delete_samples(&params.server).await;
assert_eq!(spam_training_samples(&params.server).await.total_count, 0);
// Import samples
let mut spam_ids = vec![];
let mut ham_ids = vec![];
for (idx, samples) in [&SPAM, &HAM].into_iter().enumerate() {
let is_spam = idx == 0;
for (num, sample) in samples.iter().enumerate() {
let mut mailbox_ids = vec![];
let mut keywords = vec![];
if num == 0 {
if is_spam {
mailbox_ids.push(Id::from(JUNK_ID).to_string());
keywords.push(Keyword::Junk.to_string());
} else {
mailbox_ids.push(Id::from(INBOX_ID).to_string());
keywords.push(Keyword::NotJunk.to_string());
}
} else {
mailbox_ids.push(Id::from(DRAFTS_ID).to_string());
}
let mail_id = client
.email_import(
sample.as_bytes().to_vec(),
&mailbox_ids,
Some(&keywords),
None,
)
.await
.unwrap()
.take_id();
if is_spam {
spam_ids.push(mail_id);
} else {
ham_ids.push(mail_id);
}
}
}
let samples = spam_training_samples(&params.server).await;
assert_eq!(samples.ham_count, 1);
assert_eq!(samples.spam_count, 1);
// Train the classifier via JMAP
for (ids, is_spam) in [(&spam_ids, true), (&ham_ids, false)] {
for (idx, id) in ids.iter().skip(1).enumerate() {
// Set keywords and mailboxes
let mut request = client.build();
let req = request.set_email().update(id);
if idx < 5 || !is_spam {
// Update via keywords
let keyword = if is_spam {
Keyword::Junk
} else {
Keyword::NotJunk
}
.to_string();
req.keywords([&keyword]);
} else {
// Update via mailbox
let mailbox_id = if is_spam { JUNK_ID } else { INBOX_ID };
req.mailbox_ids([&Id::from(mailbox_id).to_string()]);
}
request.send_set_email().await.unwrap().updated(id).unwrap();
}
}
let samples = spam_training_samples(&params.server).await;
assert_eq!(samples.ham_count, 10);
assert_eq!(samples.spam_count, 10);
// Reclassifying an email should not add a new sample
let mut request = client.build();
request
.set_email()
.update(&ham_ids[0])
.keywords([Keyword::Junk.to_string()]);
request
.send_set_email()
.await
.unwrap()
.updated(&ham_ids[0])
.unwrap();
let samples = spam_training_samples(&params.server).await;
assert_eq!(samples.ham_count, 9);
assert_eq!(samples.spam_count, 11);
assert_eq!(samples.samples.len(), 20);
let hold_for = params
.server
.core
.spam
.classifier
.as_ref()
.unwrap()
.hold_samples_for;
assert!(hold_for > 2 * 86400);
let hold_until = now() + hold_for;
let hold_range = (hold_until - 86400)..=hold_until;
assert!(samples.samples.iter().all(|s| s.account_id == account_id
&& s.remove.is_none()
&& hold_range.contains(&s.until)));
// Purging blobs should not remove training samples
params
.server
.store()
.purge_blobs(params.server.blob_store().clone())
.await
.unwrap();
let samples = spam_training_samples(&params.server).await;
assert_eq!(samples.ham_count, 9);
assert_eq!(samples.spam_count, 11);
assert_eq!(samples.samples.len(), 20);
// Extend hold period so a new training sample is generated
let old_core = params.server.core.clone();
let mut new_core = old_core.as_ref().clone();
new_core.spam.classifier.as_mut().unwrap().hold_samples_for += 2 * 86400;
params.server.inner.shared_core.store(Arc::new(new_core));
// Reclassifying an email will now add a new sample
let mut request = client.build();
request
.set_email()
.update(&ham_ids[0])
.keywords([Keyword::NotJunk.to_string()]);
request
.send_set_email()
.await
.unwrap()
.updated(&ham_ids[0])
.unwrap();
let samples = spam_training_samples(&params.server).await;
assert_eq!(samples.ham_count, 10);
assert_eq!(samples.spam_count, 11);
assert_eq!(samples.samples.len(), 21);
// Blob purge should remove the duplicated sample
params
.server
.store()
.purge_blobs(params.server.blob_store().clone())
.await
.unwrap();
let samples = spam_training_samples(&params.server).await;
assert_eq!(samples.ham_count, 10);
assert_eq!(samples.spam_count, 10);
assert_eq!(samples.samples.len(), 20);
test.destroy_all_mailboxes(account).await;
test.assert_is_empty().await;;
}

View File

@@ -1,247 +0,0 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use crate::jmap::{JMAPTest, ManagementApi, mail::delivery::SmtpConnection};
use mail_parser::{MessageParser, MimeHeaders};
use std::path::PathBuf;
use store::{
Deserialize, Serialize,
write::{Archive, Archiver},
};
pub async fn test(test: &mut TestServer) {
println!("Running Encryption-at-rest tests...");
// Check encryption
check_is_encrypted();
import_certs_and_encrypt().await;
// Create test account
let account = test.account("jdoe@example.com");
let client = account.jmap_client().await;
// Build API
let api = ManagementApi::new(8899, "jdoe@example.com", "12345");
// Try importing using multiple methods and symmetric algos
for (file_name, method, num_certs) in [
("cert_smime.pem", EncryptionMethod::SMIME, 3),
("cert_pgp.pem", EncryptionMethod::PGP, 1),
] {
let certs = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("crypto")
.join(file_name),
)
.unwrap();
for algo in [Algorithm::Aes128, Algorithm::Aes256] {
let request = match method {
EncryptionMethod::PGP => EncryptionType::PGP {
algo,
certs: certs.clone(),
allow_spam_training: true,
},
EncryptionMethod::SMIME => EncryptionType::SMIME {
algo,
certs: certs.clone(),
allow_spam_training: true,
},
};
assert_eq!(
api.post::<u32>("/api/account/crypto", &request)
.await
.unwrap()
.unwrap_data(),
num_certs
);
}
}
// Send a new message, which should be encrypted
let mut lmtp = SmtpConnection::connect().await;
lmtp.ingest(
"bill@example.com",
&["jdoe@example.com"],
concat!(
"From: bill@example.com\r\n",
"To: jdoe@example.com\r\n",
"Subject: TPS Report (should be encrypted)\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
),
)
.await;
// Send an encrypted message
lmtp.ingest(
"bill@example.com",
&["jdoe@example.com"],
concat!(
"From: bill@example.com\r\n",
"To: jdoe@example.com\r\n",
"Subject: TPS Report (already encrypted)\r\n",
"Content-Type: application/pkcs7-mime; name=\"smime.p7m\"; smime-type=enveloped-data\r\n",
"\r\n",
"xjMEZMYfNhYJKwYBBAHaRw8BAQdAYyTN1HzqapLw8xwkCGwa0OjsgT/JqhcB/+Dy",
"Ga1fsBrNG0pvaG4gRG9lIDxqb2huQGV4YW1wbGUub3JnPsKJBBMWCAAxFiEEg836",
"pwbXpuQ/THMtpJwd4oBfIrUFAmTGHzYCGwMECwkIBwUVCAkKCwUWAgMBAAAKCRCk",
"nB3igF8itYhyAQD2jEdeYa3gyQ47X9YWZTK1wEJkN8W9//V1fYl2XQwqlQEA0qBv",
"Ai6nUh99oDw+/zQ8DFIKdeb5Ti4tu/X58PdpiQ7OOARkxh82EgorBgEEAZdVAQUB",
"AQdAvXz2FbFN0DovQF/ACnZyczTsSIQp0mvmF1PE+aijbC8DAQgHwngEGBYIACAW",
"IQSDzfqnBtem5D9Mcy2knB3igF8itQUCZMYfNgIbDAAKCRCknB3igF8itRnoAQC3",
"GzPmgx7TnB+SexPuJV/DoKSMJ0/X+hbEFcZkulxaDQEAh+xiJCvf+ZNAKw6kFhsL",
"UuZhEDktxnY6Ehz3aB7FawA=",
"=KGrr",
),
)
.await;
// Disable encryption
assert_eq!(
api.post::<Option<String>>("/api/account/crypto", &EncryptionType::Disabled)
.await
.unwrap()
.unwrap_data(),
None
);
// Send a new message, which should NOT be encrypted
lmtp.ingest(
"bill@example.com",
&["jdoe@example.com"],
concat!(
"From: bill@example.com\r\n",
"To: jdoe@example.com\r\n",
"Subject: TPS Report (plain text)\r\n",
"\r\n",
"I'm going to need those TPS reports ASAP. ",
"So, if you could do that, that'd be great."
),
)
.await;
// Check messages
let mut request = client.build();
request.get_email();
let emails = request.send_get_email().await.unwrap().take_list();
assert_eq!(emails.len(), 3, "3 messages were expected: {:#?}.", emails);
for email in emails {
let message =
String::from_utf8(client.download(email.blob_id().unwrap()).await.unwrap()).unwrap();
if message.contains("should be encrypted") {
assert!(
message.contains("Content-Type: multipart/encrypted"),
"got message {message}, expected encrypted message"
);
} else if message.contains("already encrypted") {
assert!(
message.contains("Content-Type: application/pkcs7-mime")
&& message.contains("xjMEZMYfNhYJKwYBBAHaRw8BAQdAYy"),
"got message {message}, expected message to be left intact"
);
} else if message.contains("plain text") {
assert!(
message.contains("I'm going to need those TPS reports ASAP."),
"got message {message}, expected plain text message"
);
} else {
panic!("Unexpected message: {:#?}", message)
}
}
}
pub async fn import_certs_and_encrypt() {
for (name, method, expected_certs) in [
("cert_pgp.pem", EncryptionMethod::PGP, 1),
//("cert_pgp.der", EncryptionMethod::PGP, 1),
("cert_smime.pem", EncryptionMethod::SMIME, 3),
("cert_smime.der", EncryptionMethod::SMIME, 1),
] {
let mut certs = try_parse_certs(
method,
std::fs::read(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("crypto")
.join(name),
)
.unwrap(),
)
.expect(name);
assert_eq!(certs.len(), expected_certs);
if method == EncryptionMethod::PGP && certs.len() == 2 {
// PGP library won't encrypt using EC
let mut certs_ = certs.to_vec();
certs_.pop();
certs = certs_.into();
}
let mut params = EncryptionParams {
certs,
flags: method.flags(),
};
for algo in [Algorithm::Aes128, Algorithm::Aes256] {
let message = MessageParser::new()
.parse(b"Subject: test\r\ntest\r\n")
.unwrap();
assert!(!message.is_encrypted());
params.flags = algo.flags() | method.flags();
let arch =
Archive::deserialize_owned(Archiver::new(params.clone()).serialize().unwrap())
.unwrap();
message
.encrypt(arch.unarchive::<EncryptionParams>().unwrap())
.await
.unwrap();
}
}
// S/MIME and PGP should not be allowed mixed
assert!(
try_parse_certs(
EncryptionMethod::PGP,
std::fs::read(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("crypto")
.join("cert_mixed.pem"),
)
.unwrap(),
)
.is_err()
);
}
pub fn check_is_encrypted() {
let messages = std::fs::read_to_string(
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("resources")
.join("crypto")
.join("is_encrypted.txt"),
)
.unwrap();
for raw_message in messages.split("!!!") {
let is_encrypted = raw_message.contains("TRUE");
let message = MessageParser::new()
.parse(raw_message.trim().as_bytes())
.unwrap();
assert!(message.content_type().is_some());
assert_eq!(
message.is_encrypted(),
is_encrypted,
"failed for {raw_message}"
);
}
}

View File

@@ -70,13 +70,6 @@ message = "this should not have happened"
"#;
const RAW_MESSAGE: &str = "From: john@example.com
To: john@example.com
Subject: undelete test
test
";
pub async fn test(test: &mut TestServer) {
// Enable Enterprise
println!("Running Enterprise tests...");
@@ -166,35 +159,6 @@ pub async fn test(test: &mut TestServer) {
);
}
pub trait EnterpriseCore {
fn enable_enterprise(self) -> Self;
}
impl EnterpriseCore for Core {
fn enable_enterprise(mut self) -> Self {
self.enterprise = Enterprise {
license: LicenseKey {
valid_to: now() + 3600,
valid_from: now() - 3600,
domain: String::new(),
accounts: 100,
},
undelete: None,
trace_store: None,
metrics_store: None,
metrics_alerts: vec![],
logo_url: None,
ai_apis: Default::default(),
spam_filter_llm: None,
template_calendar_alarm: None,
template_scheduling_email: None,
template_scheduling_web: None,
}
.into();
self
}
}
async fn alerts(server: &Server) {
// Make sure the required metrics are set to 0
assert_eq!(
@@ -384,111 +348,6 @@ async fn metrics(test: &mut TestServer) {
);
}
async fn undelete(test: &mut TestServer) {
// Authenticate
let mut imap = ImapConnection::connect(b"_x ").await;
imap.authenticate("jdoe@example.com", "12345").await;
// Insert test message
imap.send("STATUS INBOX (MESSAGES)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MESSAGES 0");
imap.send(&format!("APPEND INBOX {{{}}}", RAW_MESSAGE.len()))
.await;
imap.assert_read(Type::Continuation, ResponseType::Ok).await;
imap.send_untagged(RAW_MESSAGE).await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
// Make sure the message is there
imap.send("STATUS INBOX (MESSAGES)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MESSAGES 1");
imap.send("SELECT INBOX").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
// Fetch message body
imap.send("FETCH 1 BODY[]").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("Subject: undelete test");
// Delete and expunge message
imap.send("STORE 1 +FLAGS (\\Deleted)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
imap.send("EXPUNGE").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
// Logout and reconnect
imap.send("LOGOUT").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
let mut imap = ImapConnection::connect(b"_x ").await;
imap.authenticate("jdoe@example.com", "12345").await;
// Make sure the message is gone
imap.send("STATUS INBOX (MESSAGES)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MESSAGES 0");
// Query undelete API
let api = ManagementApi::new(8899, "admin", "secret");
api.get::<serde_json::Value>("/api/store/purge/account/jdoe@example.com")
.await
.unwrap();
test.wait_for_tasks().await;
tokio::time::sleep(Duration::from_millis(200)).await;
let deleted = api
.get::<List<DeletedBlobResponse>>("/api/store/undelete/jdoe@example.com")
.await
.unwrap()
.unwrap_data()
.items;
assert_eq!(deleted.len(), 1);
let deleted = deleted.into_iter().next().unwrap();
match deleted.item {
DeletedItemResponse::Email { from, subject, .. } => {
assert_eq!(subject.as_ref(), "undelete test");
assert_eq!(from.as_ref(), "john@example.com");
}
other => {
panic!("Unexpected deleted item response: {:?}", other);
}
}
// Undelete
let result = api
.post::<Vec<UndeleteResponse>>(
"/api/store/undelete/jdoe@example.com",
&vec![UndeleteRequest {
hash: deleted.hash,
collection: "email".to_string(),
time: deleted.deleted_at,
cancel_deletion: deleted.expires_at.into(),
}],
)
.await
.unwrap()
.unwrap_data();
assert_eq!(result, vec![UndeleteResponse::Success]);
// Make sure the message is back
imap.send("STATUS INBOX (MESSAGES)").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("MESSAGES 1");
imap.send("SELECT INBOX").await;
imap.assert_read(Type::Tagged, ResponseType::Ok).await;
// Fetch message body
imap.send("FETCH 1 BODY[]").await;
imap.assert_read(Type::Tagged, ResponseType::Ok)
.await
.assert_contains("Subject: undelete test");
}
pub async fn insert_test_metrics(core: Arc<Core>) {
let store = core.storage.data.clone();
store.purge_metrics(Duration::from_secs(0)).await.unwrap();